Baton is a Slack agent that keeps work moving while someone is out of office (OOO). Built for the Slack Agent Builder Challenge with Bolt for Python + Google Gemini.
When someone goes OOO, Baton:
- Handoff brief (F1) — reconstructs their open loops from live Slack context: threads awaiting their reply, promises they made, and decisions blocked on them. Renders a Block Kit brief assigned to a cover person.
- Live coverage (F2) — while they're out, when someone @mentions them in a channel, Baton replies in-thread: either answering from the person's own message history (with a cited link) or routing to the cover person with a context card.
- Re-entry brief (F3) — when they're back, a short streamed digest: what was decided, what the cover person handled, and the top 3 things needing them today.
Slack (Socket Mode, xoxb + xapp)
│
┌────────────────────────────┼─────────────────────────────┐
│ │ │
slash commands message events interactive
/baton-handoff (mention scan for OOO user) actions / modal / Home
/baton-back │ │
└──────────────┬────────────┴───────────────┬─────────────┘
▼ ▼
listeners/* ──────────────► flows.py (orchestration)
│
┌──────────────────────┬───────────────┼───────────────┐
▼ ▼ ▼ ▼
core/retriever core/heuristics core/llm db/store
RTS │ History (flag) (pure regex) (Gemini, (sqlite3:
assistant.search.context no Slack JSON-only) handoffs,
→ replies/history open_loops,
coverage_log)
│
▼
surfaces/blocks.py + copy.py (all Block Kit + copy)
Design principle: core/ modules are Slack-free and unit-testable. flows.py
is the only place core logic, Slack posting, and the DB meet. All user-facing copy
and emoji live in surfaces/copy.py.
app.py # entrypoint — Socket Mode Bolt app
manifest.json # app config: commands, events, scopes, AI feature
.slack/hooks.json # optional: `slack run` support
src/baton/
config.py # env + tunable constants (caps, thresholds, weights)
context.py # AppContext: config, store, gemini, retriever, name cache
flows.py # F1/F2/F3 orchestration (core → Slack → DB)
util.py # pure helpers (links, mention parsing, dates)
core/
models.py # Msg, Snippet, Candidate
retriever.py # Retriever protocol + RtsRetriever + HistoryRetriever
heuristics.py # pure pre-filter regex + awaiting-reply logic
llm.py # Gemini calls + defensive JSON parsing + prompts
openloops.py # F1 pipeline + ranking
coverage.py # F2 answer/route decision
briefs.py # F3 re-entry payload builder
surfaces/
blocks.py # every Block Kit builder
copy.py # all copy + emoji vocabulary
db/
schema.sql # 3 tables
store.py # typed sqlite3 helpers
scripts/seed.py # demo seeder
tests/ # pytest units
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt- Slack CLI:
slack create baton --template <this repo>then it readsmanifest.json, or - Manually: https://api.slack.com/apps → Create New App → From a manifest →
paste
manifest.json.
Then:
- Basic Information → App-Level Tokens: create a token with
connections:write(this is yourSLACK_APP_TOKEN,xapp-…). - OAuth & Permissions: install to your workspace → copy the Bot User OAuth Token
(
SLACK_BOT_TOKEN,xoxb-…). - Invite the bot to the channels it should watch:
/invite @Baton.
assistant.search.context (Real-Time Search) must be called with a user token
(xoxp-…) carrying search:read.public (+ the other search:read.* scopes for
IMs/private/files). This comes from the OOO user authorizing the app's user scopes
during OAuth. After install, copy the User OAuth Token into SLACK_USER_TOKEN.
If you don't set this up, leave
RETRIEVER=history(the default) — Baton works fully without a user token by scanningconversations.historyover the bot's channels.
Get an API key from Google AI Studio →
GEMINI_API_KEY. Default model is gemini-flash-latest (override with GEMINI_MODEL;
gemini-3.5-flash also works). Note gemini-2.5-flash is deprecated for new keys.
cp .env.sample .env # then fill in the tokens/keysPrimary (reliable):
source .venv/bin/activate
python3 app.pyVia Slack CLI:
slack run # uses .slack/hooks.json + slack-cli-hooksOn boot Baton applies the SQLite schema, selects a retriever from RETRIEVER, and
opens a Socket Mode connection. Open the Baton app's Home tab to see the idle state.
python3 scripts/seed.py --user U0123ABC # the OOO user's idThe seeder posts a scripted set into SEED_CHANNELS: 3 awaiting-reply threads,
2 promises, 2 blocked decisions, and 2 already-resolved loops — then prints a checklist.
Author identity matters. Baton keys promises/resolved loops on the message author. Bot-posted messages are authored by the bot, so:
- Set
SEED_AS_USER_TOKENto post those as the real OOO user, or - Leave it unset and the seeder prints the exact texts to hand-post as that user (post the fulfillment replies in-thread).
Then run /baton-handoff @cover until:2026-07-20 — the brief should contain the seeded
loops and exclude the 2 resolved ones (they're logged, not shown).
app_mention only fires for mentions of the bot. To notice when someone @mentions
an OOO user, Baton subscribes to message.channels/message.groups and, for every
active handoff, scans the raw event text for <@{OOO_USER_ID}>
(listeners/events.py → util.contains_mention). Bot messages and edited/deleted/system
subtypes are ignored so Baton never reacts to itself or crashes on a malformed event.
The retriever follows the documented Real-Time Search pattern:
assistant.search.context (user token) → identify relevant thread/channel
│
▼
conversations.replies (thread) or conversations.history (channel) (bot token)
Baton never uses the legacy search:read scope or search.messages/search.all.
HistoryRetriever implements the same Retriever interface by scanning
conversations.history over the bot's channels — switch with RETRIEVER=rts|history.
Both are capped at ~200 messages / ~30 LLM-classified candidates.
source .venv/bin/activate
pytest -qCovers: commitment/decision regex heuristics, awaiting-reply thread logic, loop ranking (deadline → type weight → recency), defensive JSON parsing of LLM output, and a retriever interface-parity smoke test (both backends against a fake client).
| # | Check | How to verify |
|---|---|---|
| 1 | slack run / python app.py starts clean; Home renders both states |
run + open Home tab |
| 2 | /baton-handoff brief includes seeded loops, excludes the 2 resolved |
seed then run command |
| 3 | Mentioning the OOO user answers (seeded Q) or routes (novel Q) in-thread | @mention in a seeded channel |
| 4 | /baton-back (or Assistant panel) streams a re-entry brief from coverage_log |
run after some coverage |
| 5 | Both retrievers pass the same smoke test via the env flag | pytest -q |
| 6 | pytest green | pytest -q |
Items 1–4 need a live workspace + tokens (and, for RTS, the user-token OAuth). Items 5–6 run fully offline.
Calendar integrations · team handoffs · analytics · GitHub integration · offboarding mode · scheduling.