Architecture
Three services and two model providers, none of them a machine anybody has to keep running.
flowchart LR U["members
in Discord"] -->|"slash command
button, select, form"| D["Discord
application"] D -->|signed POST| W["Cloudflare Worker
bunker-bot"] W -->|REST| N["Notion
11 databases"] W --> KV["KV
caches"] W --> R2["R2
photos · audio
transcripts"] W -->|"speech to text"| G["Groq
Whisper"] W -->|"minutes, tasks"| M["Gemini"] CRON["cron */10"] --> W W -->|"embeds, DMs"| D
Discord is the interface. The app runs on HTTP interactions, not a gateway connection — Discord posts each interaction to the Worker and waits for a reply. Every request is Ed25519-signed and verified before it is parsed; an unsigned or badly-signed request gets a 401 and is never processed.
The Worker is all the logic. One file, no npm dependencies, pasteable into the Cloudflare dashboard. It holds no state of its own beyond its caches and object stores.
Notion is the database and the human interface to the data. Anything the bot writes is visible and editable by a person, and anything a person edits is picked up within ten minutes.
The model providers are optional and separable. Groq transcribes; Gemini writes the minutes and extracts the tasks. Either can be swapped for Workers AI, and with neither key set every other part of the bot carries on untouched — only /notes refuses, and it says why.
The load-bearing design decision: the entire catalogue lives in one KV blob rebuilt every ten minutes. Search, browse and autocomplete never call Notion at all. That is what keeps a 250-person club inside Notion's ~3 requests-per-second ceiling — without it, twenty people typing in an autocomplete box at once would rate-limit the workspace.
Data model
Three invariants hold the whole thing up. Breaking any of them corrupts counts silently, which is the worst way for an inventory system to fail.
The ledger is append-only
Counts are never stored. On Hand is derived: Qty Stocked plus every ledger row's effect. To change a count you add a row; you never edit a number. This is immune to lost updates, keeps rows at zero instead of deleting them, and gives a complete audit trail for free.
Every item carries three numbers
| Number | Means | Moved by |
|---|---|---|
| On Hand | What the club physically holds | Out subtracts · Return, Restock, Adjust add |
| Reserved | Committed to projects. Still club property, just spoken for. | Allocate adds · Release subtracts |
| Available | On Hand − Reserved. What anyone can actually take. | Derived. Every gate checks this, never On Hand. |
Allocation deliberately does not change On Hand. The club still owns the gearhead; it just belongs to ARCTOS now.
The approval gate is arithmetic, not policy
A ledger row with Status of Pending or Denied contributes zero to every count. The gate is therefore real rather than advisory — a pending request cannot move stock even if someone forgets to look at it. Approving it in Notion or in Discord does the same thing.
A blank Direction subtracts. This is deliberate and it is the fail-safe direction: a malformed row makes the bunker look emptier than it is, which gets noticed. The opposite default would silently invent stock.
Formulas are duplicated, by necessity
The Notion API never returns formula or rollup values — only an opaque reference. The Worker therefore recomputes On Hand, Reserved and Available itself, mirroring the Notion formulas exactly, including the two rules above.
This means the Notion formula and the Worker code must be changed together, by hand, forever. It is the single most likely source of a future bug. If counts in Discord ever disagree with counts in Notion, this is why.
Serials are one fixed shape
BURC-0001 through BURC-9999, then BURC-10000 — padded to a minimum of four digits, never truncated. Notion's own column shows the raw number; the bot is what people read and print, and it shows this. Every lookup normalises first, so 123, burc-123, a scanned barcode and an old unpadded label all resolve to the same item, and BURC-123 can never be mistaken for BURC-1230.
Proposals are not assignments
Anything the bot infers — a task lifted from a transcript, an owner guessed from context, a supplier parsed out of a part number — lands in a state that is visibly incomplete and waits for a person. Tasks arrive as Proposed with nobody on them; the supplier backfill leaves a field empty rather than writing a guess. A guess written into a structured field looks like a fact forever afterwards, and somebody acts on it eighteen months later with nothing in the record to say it was ever uncertain.
Notion databases
Eleven data sources. The IDs are what the Worker's variables point at.
| Database | Variable | Data source ID | Holds |
|---|---|---|---|
| Bunker Inventory | INVENTORY_DS | 37c50591-221f-80c8-ae9d-000bebcea235 | 136 items, serials, locations, tiers, photos, flags, supplier fields |
| Bunker Checkout Log | LEDGER_DS | d900e4a9-df58-4de8-baa0-60c389fdaf6d | Every movement, ever. Append-only. |
| Projects | PROJECTS_DS | fd8587db-eafe-4dd3-99cf-a5d1d688bbb1 | Agrobot, ARCTOS, Exoglove, Bunker infrastructure |
| Meetings | MEETINGS_DS | d9f5fa89-e8de-4e20-b5e0-83b69f912ca2 | The calendar. Self-populating from Discord events. Minutes land here. |
| Members | MEMBERS_DS | e935e4bb-cbc6-4a92-a2f1-e086bec48a55 | The roster. Self-populating from check-ins. |
| Attendance | ATTENDANCE_DS | 9c1d2b64-66de-4963-a69f-8c8dc37051ba | One row per person per meeting |
| Tasks | TASKS_DS | f5140aa3-138a-4f57-adf7-d89c961bad4f | Who owes what, blocks, due dates, project links |
| Purchase Requests | PURCHASES_DS | 2cb13198-de4b-4abf-86fc-295d3da631d2 | What the club wants to buy, and where each request got to |
| Bring Requests | REQUESTS_DS | cee8c7c0-cf01-4e28-b153-a8e62a2c96b0 | The pre-meeting pick list |
| Nudge Templates | TEMPLATES_DS | 18970188-f9bd-404b-bf2a-a999b760e00b | The words the retention DMs use |
| Member Feedback | FEEDBACK_DS | 9c7027ce-591e-433c-adf5-a4284285f3f3 | Why people stopped coming |
Access is per-database, not per-workspace. A database the integration has not been connected to returns a Notion 404 — the API genuinely cannot see it. Most of these live under the Club Operations page; connecting that page grants all of its children at once. A database created later is not connected automatically, which is the usual cause of a feature that worked in testing and 404s in production.
Columns the bot owns
Edit these by hand and they will be overwritten:
- Members → Name — the live Discord server nickname, refreshed on every check-in and every nudge. Put durable notes about a person in
Notes. - Members → Last Seen, Status, Nudges Sent, Last Nudged — retention state.
- Inventory → Flag, Flag Note — set by
/report, cleared by the resolve button. - Checkout Log → Decided By — this is also what stops an approval being announced twice.
- Tasks → Block Msg —
channelId|messageIdof the card announcing a block, so clearing it can strike that card through. Meaningless to a human and safe to ignore. - Tasks → Nudges, Last Nudged, Blocked Since — chase state.
Columns the bot fills but never overwrites
The supplier backfill writes Supplier, Supplier PN and MPN on inventory rows only where they are empty. Anything a person has typed is left alone permanently, so the backfill is safe to re-run after hand-fixing rows.
HTTP routes
| Route | Method | Auth | Purpose |
|---|---|---|---|
| / | POST | Ed25519 signature | The Discord interactions endpoint. Everything interactive arrives here. |
| /health | GET | none | Config report: which variables are set, which model providers are live, how many minutes jobs are queued, the check-in window, and the first error it hits. |
| /register | GET | ?secret= | Pushes the command list to the guild. Add &scope=global to publish for DMs instead — see §06. |
| /refresh | GET | ?secret= | Forces a cache rebuild. /refresh in Discord does the same thing with a diff. |
| /backfill | GET | ?secret= | Splits prose part numbers into structured supplier fields. Dry by default; &dry=0 applies, &start=N resumes. See §09. |
| /img/<key> | GET | none, public | Serves an item photo from R2, immutable, cached for a year. |
| /i/<serial> | GET | none, public | The permanent address a printed label's QR encodes. 302 to the item's Notion page. Any spelling of the serial. |
| /labels/next · done · failed | GET · POST | ?secret=PRINT_SECRET | The printer's side of the label queue. The Pi polls next, prints, and confirms. See §09. |
/health is the first thing to open when anything is wrong. Every route is wrapped in a try/catch that returns readable JSON — an uncaught throw here would surface as an opaque Cloudflare 1101 with nothing to debug.
Slash commands
Thirty-seven, guild-scoped. Every reply is ephemeral — only the person who ran it sees it. Transactions are announced separately in the log channel, so testing never spams the server.
Thirty-seven is a lot. A new member sees the whole list and will use four of them. /help hides E-Board commands from members, which halves what most people see, but the next thing added should probably replace something rather than lengthen the list.
Finding things
| Command | Access | Does |
|---|---|---|
| /find | all | Searches name, part number and keywords. load cell returns the FX29 cells and the load-cell amplifiers. |
| /browse | all | Nine sorts — stock, price, total value, most allocated, needs restocking — filterable by category and tier, paginated. |
| /item | all | Full card: three counts, reorder point, location, tier, value, photo, sourcing, any flag. |
| /stats | all | Totals and what needs attention. |
| /help | all | Reads the live command list; hides E-Board commands from members. |
| /refresh | E-Board | Pulls from Notion now and reports a before/after diff. Skips the call if the index is under 30 s old. |
Stock movement
| Command | Access | Does |
|---|---|---|
| /checkout | E-Board | Borrow. Refuses more than Available. Approval-tier items route to the approvals channel and move nothing until decided. |
| /return | all | Close out a borrow. The picker offers only what you are holding, and the quantity is capped at your outstanding balance. |
| /restock | E-Board | Log a shipment arriving. |
| /allocate | E-Board | Commit hardware to a project. Moves Available into Reserved; On Hand does not change. |
| /release | E-Board | Give allocated hardware back to the shared pool. |
| /mine | all | Your tab: what you hold, what is overdue, what is awaiting approval. |
| /report | all | Flag broken / missing / miscounted / needs label. Posts a card with a resolve button. |
| /bring | all | Ask for something to be carried to the next meeting. Creates no ledger movement. |
| /requests | E-Board | The pick list, grouped by shelf. |
| /intake | E-Board | Create an item and get its permanent serial back. Optional photo. Auto-flags Needs label if no location was given. |
| /photo | E-Board | Add or replace an item photo. Deletes the old object. |
| /label | E-Board | Queue a label for the printer in the bunker. /intake … label:True does the same for a new part. |
Projects
| Command | Access | Does |
|---|---|---|
| /project | all | Summary, lead, status, dates, value held, biggest allocations. |
| /resources | all | Every line allocated to one project, paginated. |
Work
| Command | Access | Does |
|---|---|---|
| /task | E-Board | Assign work directly, with an optional due date, priority, area and project. |
| /tasks | all E-Board for others | What is on your plate, grouped overdue / today / coming up / no date. Filter by status and project; filtering by project shows everyone's work on it. |
| /done | all | Close a task. Yours, or anyone's if you are a lead. |
| /block | all | Say who you are waiting on and what you need. Transfers the chasing to them. |
| /unblock | all | Clear a block. The blocker, the owner, or a lead can do it; the owner is told they can carry on. |
| /retask | E-Board | Re-read a meeting's transcript and propose its tasks again. Bins unassigned proposals, keeps anything somebody owns. |
Buying
| Command | Access | Does |
|---|---|---|
| /request | all | Ask the club to buy something. Files as Pending and posts an approve/deny card. |
| /orders | E-Board | Every approved request grouped by supplier, as paste-ready part,qty lists. |
Meetings and attendance
| Command | Access | Does |
|---|---|---|
| /here | all | Check in. Only works while a meeting is open; once per person per meeting. |
| /attendance | all E-Board for others | Emoji month grid plus attended, rate, current and best streak. |
| /roll | E-Board | Who is checked in right now, and when check-in shuts. |
| /event | E-Board | Creates the Discord scheduled event and the Notion row. Reads tomorrow 6pm, friday 18:30, 9/18 5pm. |
| /meeting | E-Board | Opens an ad-hoc meeting immediately, no calendar entry. |
| /checkin-button | E-Board | Posts the permanent check-in button. Run once, ever, then pin it. |
| /notes | E-Board | Hand in a recording. Transcribed, summarised and filed on the meeting's Notion page; tasks proposed from it. See §07. |
Buttons and forms
Every control encodes its whole meaning in a custom_id of at most 100 characters. Nothing is stored server-side, so pagination costs zero writes and a button pressed three days later still works.
| Prefix | Shape | Access | Does |
|---|---|---|---|
| pg| | pg|mode|arg|page | all | Prev/Next on any list |
| ap| | ap|pageId|y·n | E-Board | Approve or deny a pending stock request |
| fx| | fx|pageId | E-Board | Clear an item's flag |
| hi| | hi|now | all | Check in; resolves the open meeting at press time |
| bx| | bx|all | E-Board | Mark every open bring-request as brought |
| wl| | wl|reasonIndex | all | Exit-survey answer |
| ta| | ta|taskId | E-Board | Member select — assigns a proposed task |
| tp| | tp|taskId | E-Board | Project select — files a task under a project, or clears it |
| td| | td|taskId | E-Board | Opens the due-date form |
| tx| | tx|taskId | E-Board | Not a task — drops a proposal |
| kd| | kd|taskId | owner or lead | Done, from a DM or a digest |
| ku| | ku|taskId | blocker, owner or lead | Unblock, from a DM or a digest |
| pq| | pq|pageId|y·n | E-Board | Approve or deny a purchase request |
| pm| | pm|supplier | E-Board | Mark every approved line for one supplier as ordered |
| hw| | modal | all | Check-in word prompt |
| wo| | modal | all | Exit-survey free text |
| tdm| | modal | E-Board | Due-date text — reads friday, tomorrow, 9/18 |
Nothing is trusted from the client. Role checks run in the Worker against the roles Discord signs into the interaction, never against anything in the button. A copied custom_id gets a permission check, not a free approval. The exit-survey buttons carry only which reason was pressed — the person is identified from the clicking user, so one cannot file feedback as somebody else.
Why the DM buttons exist
A DM is a push, so the push carries the action. Tapping Done in a DM beats going back to the server to type /done, and buttons work in DMs with no extra machinery.
Slash commands do not. Guild-scoped commands can never appear in a DM at any price — that is a Discord rule, not a configuration. /register?scope=global exists and does publish commands that work in DMs, but global commands take minutes to an hour to propagate and the three that pick a member or post to a channel still cannot work there. Buttons were the better answer; the global path is left in for anyone who wants to revisit it.
A DM interaction also carries user but no member, so every role check would silently fail. The Worker looks the person up in the guild once per interaction and synthesises the member object, and if they have left the server it says so rather than quietly granting nothing.
Meetings to minutes
The longest-running thing the bot does, and the only one that cannot fit inside a Discord interaction.
flowchart TD A["/notes with a recording"] --> B["parked in R2
pending/id"] B --> C["cron picks it up
15-minute budget"] C --> D["Groq Whisper
transcript"] D --> E["Gemini
minutes"] D --> F["Gemini
task extraction"] E --> G["Notion meeting page
summary, decisions, transcript"] F --> H["assignment cards
nobody assigned yet"] D --> I["kept in R2
text/meetingId"] I -.->|"/retask"| F C --> J["audio deleted"]
Why the cron and not the command
ctx.waitUntil() is capped at 30 seconds after the reply is sent, and a Worker killed at that cap reports nothing at all — which looks exactly like the bot stalling mid-sentence. Transcribing an hour of audio does not fit in 30 seconds. Cron triggers get fifteen minutes, so the command parks the file and returns immediately, and the next scheduled pass does the work. Expect minutes within about ten minutes of handing in the recording.
A job that keeps failing is retried for 45 minutes, then moved to failed/ so it stops consuming a slot every ten minutes, and the failure is reported with the error attached.
What gets kept, and what does not
The audio is deleted the moment the transcript exists. The transcript is written to the Notion page inside a collapsed toggle, and a copy is kept in R2 under text/ so /retask can re-read it later. That copy is the same words already on the Notion page — it is somewhere the Worker can read cheaply, not a second disclosure. Transcripts are a few kB each; a decade of weekly meetings is under a megabyte.
Recording consent is a legal question in Massachusetts, and the test is awareness, not paperwork. M.G.L. c. 272 § 99 makes secret recording a felony and has no participant exception — it targets secrecy itself, so what matters is that people in the room knew. The club's practice is a standing notice in the bunker plus a verbal announcement before recording starts. /notes requires the person filing to confirm both happened, and that confirmation is written onto the Notion page as provenance.
There is deliberately no announcement in Discord — people not in the room are not the ones being recorded, and a channel notice is the wrong instrument for a room-level fact. CONSENT_NOTE overrides the wording if the practice changes.
Which model does what, and why
| Step | Provider | Why |
|---|---|---|
| Transcription | Groq Whisper, else Workers AI | Groq chunks long audio server-side and takes the file as a stream, which a Worker cannot do for itself. It also accepts mp4 and webm containers and transcribes the audio track, so a video file works without stripping anything. |
| Minutes and tasks | Gemini, else Groq, else Workers AI | Gemini reads the whole meeting at once. That matters: a decision at minute 70 can be connected to the discussion at minute 12, which map-reduce structurally loses. |
Groq's free tier allows 6,000 tokens per minute including input. A 90-minute transcript is roughly 16,000 tokens, so it cannot be summarised in one call there at all — it forces small chunks and worse minutes. Gemini allows a million. That is the whole reason two providers are wired in rather than one.
Model failures are two different problems
Size limits
The cap is 24 MB, just under Groq's free-tier file limit. A 90-minute meeting recorded as compressed audio is about 20 MB and fits; on iPhone that is Settings → Voice Memos → Audio Quality → Compressed. Video is accepted as a container but is 10–50× larger for the same speech, so in practice only short clips fit. A url: option takes a direct link for files too big to attach to Discord.
Tasks
Two ways in: assigned directly with /task, or extracted from a meeting. The extracted ones are the interesting half.
Extraction asks what work the meeting created
This is a dedicated pass over the raw transcript, not a scrape of the minutes. The distinction is the whole point, and it was learned the hard way.
The minutes have an Action items section, and reading it seems obviously right. But that section answers a different question — what did somebody say out loud that they would do — and in a real club meeting the answer is almost nothing. People decide to show a video on a TV; nobody ever says "I will edit the video." The work is implied by the decision. Scraping stated commitments misses all of it while faithfully capturing whichever passing remark happened to have a name next to it.
So the extractor is told plainly: for every decision, ask what somebody has to physically buy, build, book, write, bring, fix or ask before that decision is true. It returns structured data, and each proposal carries:
| Field | Is |
|---|---|
| what | Imperative and concrete, with the specifics the transcript gave. "Buy candy at Walmart with the $75 Ignite budget", not "Handle refreshments". |
| basis | stated — somebody said they would. implied — it follows from a decision and nobody volunteered. |
| why | The decision it came from, so a reviewer can sanity-check it at a glance. |
| due | Only a deadline that was actually said, parsed into a real date. An unparseable one is written into the notes rather than dropped. |
Expect this to produce more cards than a scrape would, some of them noise. That is the intended trade: Not a task kills a bad one in a single tap, and a task nobody proposed is invisible forever.
Nothing is auto-assigned, ever
A transcript has no speaker labels, so any owner the model names is an inference from context rather than a fact. Auto-assigning on an inference nags people for work they never agreed to, which is exactly how a task system loses everyone's trust. Every extracted task is filed as Proposed with nobody on it, and a card goes to the assignment channel:
📋 TSK-9 Source a TV for the demo table Project: ARC · ARCTOS Nobody volunteered for this — it follows from showing video instead of the robot. No owner was named. [ Assign to… ▾ ] [ Project: ARC · ARCTOS ▾ ] [ Set a due date ] [ Not a task ]
The guess, where there is one, is shown and never applied. The project select stays usable after assigning — filing and assigning are separate decisions, and a control that vanishes after one use is worse than one that stays.
Blocking transfers the chasing
/block takes who you are waiting on and what you need from them. The task moves to Blocked, the blocker is DMed with an Unblock button, and the daily chase switches from the owner to the blocker — the person who can actually move it. Clearing it tells the original owner they can carry on and restarts their clock at zero.
The card announcing a block is edited in place when it clears, rather than left lying or deleted, so the record of how long the block lasted survives. That is what Block Msg on the Tasks database is for.
Re-running
Filing twice would double every card, so a second /notes on the same meeting files nothing — but it now says so in the minutes embed, because a silent no-op looks identical to extraction finding nothing. /retask is the deliberate redo: it bins the unassigned proposals, keeps anything somebody already owns, and extracts again from the stored transcript.
Purchasing
Turns scattered "can we buy…" messages into one approved list per supplier that somebody pastes and pays for.
flowchart LR A["/request"] --> B["Pending"] B -->|"lead approves"| C["Approved"] B -->|"lead denies"| D["Denied"] C -->|"/orders → Mark ordered"| E["Ordered"] E -->|"box arrives"| F["Received"] F -->|"/restock"| G["on the shelf"]
No cart API is involved, and none is needed
DigiKey, Mouser and Amazon Business all accept a pasted part,quantity list in their bulk-order box. That cannot break when a vendor redesigns their site, needs no API key, and works identically for suppliers with no API at all. /orders produces exactly that, grouped by supplier:
📦 Ready to order — 3 lines DigiKey — 2 lines · $14.00 1N4148,50 MAX11206EEE+-ND,2 Paste straight into their bulk order box. McMaster — 1 line · $20.00 91290A115,100 This supplier has no paste box — order these by hand. About $34.00 in total [ Mark DigiKey ordered ] [ Mark McMaster ordered ]
Suppliers that have no paste box say so rather than pretending. Lines with no recorded price are counted separately instead of silently treated as free.
The supplier backfill
Most inventory part numbers were recorded as prose — sometimes a distributor SKU, sometimes an MPN in brackets, sometimes a note about what could not be confirmed. /backfill splits the unambiguous ones into Supplier, Supplier PN and MPN.
| Rule | Example | Gives |
|---|---|---|
| digikey+mpn | 497-VL53L3CXV0DH/1CT-ND (MPN VL53L3CXV0DH/1) | Supplier, SKU and MPN |
| digikey | MAX11206EEE+-ND | Supplier and SKU |
| digikey (mpn unclear) | 507-1549-ND (Bel Fuse — exact MPN not resolved) | SKU only. The hedging is about the manufacturer number; the SKU in front of it is not in doubt and is all you need to reorder. |
| mpn only | ADC121S021 (MPN ADC121S021CIMF/NOPB) | MPN, no supplier claimed |
| bare | HRPG-1000-12 | MPN, no supplier claimed |
Anything else is left alone: CM3+ (variant to confirm: /Lite, /8GB…), 621772 (motor) / V692821-1.1 (gearhead), Cast marking '402 666 24V'. Those need somebody holding the part, and an empty field is honestly incomplete where a guess is quietly wrong.
It runs in windows. A live pass writes at most 40 rows and returns the &start= to resume from, because 136 writes in one invocation would blow the 50-subrequest ceiling and die halfway with no record of where it stopped. A dry run costs nothing per row and walks the whole catalogue, so run it dry first and read the counts.
Deliberately not built
- Automatic reordering. A bot that spends club money with no person in the loop is a bad idea at any budget.
- Fuzzy matching from descriptions. "12V power supply" matches four hundred parts and picks the wrong one silently. Exact MPN matching is reliable; description matching is not, and its failures are quiet.
- Parametric search in Discord. DigiKey's own site is better at this than any embed. The bot's job is the record, not the browsing.
- Automatic substitutions. "Equivalent" is an engineering judgement.
Labels
A Raspberry Pi in the bunker runs labeld, polls /labels/next every five seconds, renders the label, prints it, and confirms. The Worker never pushes — the Pi has no inbound route and needs none. A job claimed but not confirmed within ten minutes goes back in the queue, so a Pi that loses power mid-label loses nothing. Failures post to the errors channel with the serial.
The label carries the serial three ways for three readers: a QR of PUBLIC_BASE/i/BURC-0123 for phones, Code 128 bars along the bottom for the keyboard-wedge scanner, and the text for people. The renderer adapts to any tape from 12 mm up; bars are dropped under 18 mm where they would be too short to scan. Which printer is behind the Pi is a config line — CUPS, Brother QL, or a captured raw stream for the Epson. The Pi folder's README carries the decision tree.
Amazon has no usable API for this. Product Advertising API 5.0 returns 403 for non-affiliates and its replacement is affiliate-only; cart operations died with PA-API 4.0; and no public API exposes restock dates at all. Amazon is a paste list and a URL, permanently.
Scheduled jobs
One cron trigger — */10 * * * * — dispatches everything by checking the clock. Cloudflare allows five triggers on the free plan; this uses one, and adding a job costs nothing.
| When (club local) | Job | Goes to |
|---|---|---|
| every 10 min | Rebuild the catalogue cache; announce approvals made in Notion | log channel |
| every 10 min | Rebuild the attendance cache | — |
| every 10 min | Minutes queue — one job per run: transcribe, summarise, file, propose tasks | minutes + assignments |
| every 10 min | Pick list check for meetings coming up | approvals |
| daily 08:00 | Task digest — DMs each owner what they owe, tells leads what is rotting | DM, then tasks channel |
| daily 09:00 | Overdue sweep — DMs borrowers, escalates at 3 days or if the DM bounces | DM, then approvals |
| every 6 h | Meeting sync from Discord scheduled events (max 5 new per run) | — |
| T−2 h | Pick list for each upcoming meeting, grouped by shelf. Fires once per meeting. | approvals |
| Wed 17:00 | Retention nudges — max 6 per run | DM, summary to nudge channel |
| Sun 19:00 | Restock digest, priced, with a refill total | approvals |
| Sun 20:00 | Inventory-gaps digest — missing name, location, count, price, photo; flagged rows | approvals |
Do not change the cron without changing the code. Each dated job checks whether the current firing falls in its ten-minute slot (minute < 10). Make it coarser — */30 — and most jobs never fire at all, silently. Make it finer — */1 — and each one fires ten times in a row. The minutes queue and the pick-list check are the exceptions: they run on every firing on purpose, because both are idempotent and latency matters for them.
Why ten minutes and not one
Cloudflare permits * * * * *, and 1,440 firings a day is nothing against 100,000 requests. The wall is KV writes: 1,000 a day on the free plan, and every firing writes both caches.
| Cron | Firings/day | KV writes/day | Verdict |
|---|---|---|---|
| */10 | 144 | ~290 | Current. Comfortable. |
| */5 | 288 | ~580 | Fine, with headroom for interactive writes. |
| */2 | 720 | ~1,440 | Over budget. |
| */1 | 1,440 | ~2,880 | Nearly 3× over. |
The fix, if a faster cron is ever wanted: write to KV only when the rebuilt blob actually differs from the cached one. Reads are the cheap resource (100,000 a day), writes the scarce one, so comparing before writing collapses steady-state writes to near zero. The slot checks would need narrowing to minute === n at the same time.
Worth knowing what the cron is actually for, though: stock writes already clear their own cache, so a checkout is reflected on the very next read regardless of cadence. The timer exists to notice edits made in Notion by a person. Ten minutes is a reasonable latency for that, and a faster cron buys very little for what it spends.
Retention rules
A member is DM'd only when every one of these is true. They are conservative on purpose — the failure mode of a retention system is becoming the thing people mute.
- They have attended at least once. The bot never chases somebody who never came.
- They missed both of the last two meetings.
- They have not been nudged in 7 days.
- They have had fewer than 2 nudges, ever.
No DMsis unticked and their status is not Alumni or Left.- They are still in the server — checked live; a leaver is marked Left and never messaged.
Attending anything resets the counter to zero. The second nudge carries the exit survey and is the last message that person ever receives from the bot. Nudges address people by their server nickname, not any name they may never have filled in on a Notion row.
Configuration
Bindings
| Name | Type | Without it |
|---|---|---|
| CACHE | KV namespace | Still works, but calls Notion on every interaction. Will rate-limit under load. |
| IMG | R2 bucket | Photo commands refuse with a clear message. Everything else is unaffected. |
| AUDIO | R2 bucket | /notes refuses — transcription runs on the cron, so the recording has to be parked somewhere first. /retask and the label queue also live here. |
| LABELS | R2 bucket | Optional. The label queue uses it if bound, else AUDIO, else IMG. |
| AI | Workers AI | Only a fallback. Used for transcription or summarising if no API key is set. |
Required variables
Missing any of these and /health returns a 500 naming them.
| Name | Type | Is |
|---|---|---|
| DISCORD_APP_ID | text | Application ID |
| DISCORD_PUBLIC_KEY | text | Verifies every incoming signature |
| DISCORD_BOT_TOKEN | secret | Posting, DMs, events, member lookups |
| DISCORD_GUILD_ID | text | Command registration, event scope, and the nickname lookup for DM interactions |
| NOTION_TOKEN | secret | Integration secret, ntn_… |
| REGISTER_SECRET | secret | Guards /register, /refresh and /backfill |
| PRINT_SECRET | secret | Guards the label queue. Lives on the Pi, so it is not REGISTER_SECRET — a Pi in a shared room must not be able to re-register commands. |
| INVENTORY_DS | text | See §03 |
| LEDGER_DS | text | See §03 |
| PROJECTS_DS | text | See §03 |
Optional variables
Each feature is inert without its own variables and says so rather than failing.
| Name | Default | Controls |
|---|---|---|
| EBOARD_ROLE_ID | — | Every lead-only gate. Unset means nobody passes. |
| LOG_CHANNEL_ID | — | Public transaction log |
| APPROVALS_CHANNEL_ID | — | Approvals, digests, alerts, pick lists |
| ERRORS_CHANNEL_ID | — | The bot's own exception reports |
| NUDGE_CHANNEL_ID | approvals | Retention summaries and survey answers |
| ASSIGN_CHANNEL_ID | approvals | Where task proposals go to be assigned |
| TASKS_CHANNEL_ID | assign → approvals | Blocked cards and the daily task digest. A blocked task is not an approval, so it gets its own home if you want one. |
| MINUTES_CHANNEL_ID | log | Where finished minutes are posted |
| MEETINGS_DS | — | Attendance, all of it |
| MEMBERS_DS | — | Attendance, all of it |
| ATTENDANCE_DS | — | Attendance, all of it |
| TASKS_DS | — | Every task command, the digest, and extraction from minutes |
| PURCHASES_DS | — | /request and /orders |
| REQUESTS_DS | — | /bring and the pick list |
| TEMPLATES_DS | built-in copy | Editable nudge wording |
| FEEDBACK_DS | — | The exit survey |
| GROQ_API_KEY | — | Transcription, and summarising if Gemini is unset |
| GEMINI_API_KEY | — | Minutes and task extraction. Strongly preferred — see §07. |
| GEMINI_TEXT_MODEL | auto-discovered | Pins a model instead of letting the Worker pick and remember one |
| GROQ_STT_MODEL | whisper-large-v3-turbo | Transcription model |
| GROQ_TEXT_MODEL | llama-3.3-70b-versatile | Summarising, when Groq is the text provider |
| CONSENT_NOTE | built-in wording | The provenance line written onto every set of minutes |
| PUBLIC_BASE | — | The Worker's own URL, for photo links. No trailing slash. |
| TZ_OFFSET | -4 | Club local time. Set to -5 on 1 November. |
| CHECKIN_EARLY_MIN | 30 | How early check-in opens |
| CHECKIN_GRACE_MIN | 15 | Grace after a stated end time |
| CHECKIN_LATE_MIN | 120 | Window when no end time is known |
| STT_TIMEOUT_MS | 180000 | Transcription deadline |
| LLM_TIMEOUT_MS | 60000 | Per model call |
| TRANSCRIBE_BUDGET_MS | 300000 | Whole-transcription budget inside the cron |
Variables and bindings do not take effect until you Deploy again. Adding one in Settings and walking away is the most common way to spend twenty minutes debugging nothing.
Discord permissions the bot needs
- Send Messages and Embed Links in the log, approvals, errors, nudge, assignments, tasks and minutes channels — and it must be a member of the private ones, or posts vanish silently.
- Manage Events for
/event. - Scopes
botandapplications.commandsat invite time.
Caches and object stores
KV
| Key | Holds | Rebuilt | Cleared by |
|---|---|---|---|
| index:v5 | Whole catalogue, projects, open borrows, pending requests | cron, or on demand | any stock write |
| attend:v1 | Meetings, members, attendance rows, every task | cron | check-in, new meeting, any task write |
| decided:v1 | Approval row IDs already announced | cron | never |
| picked:v1 | Meetings whose pick list has gone out | as needed | never (last 200 kept) |
| gemini:model | The model chosen after a 404 forced rediscovery | on a 404 | never (a 503 must not touch it) |
The version suffix is load-bearing. When the cached object's shape changes, bump it — otherwise KV serves the old shape and a new field reads as empty with no error anywhere. This cost an evening once: /project autocomplete returned nothing because the cache still held a pre-projects blob. Both caches now also validate their shape on read and rebuild if it is wrong, so the failure is self-healing, but bumping the key is still the correct move.
R2
| Bucket / prefix | Holds | Lifetime |
|---|---|---|
| IMG | Item photos, served publicly at /img/<key> | Until the item's photo is replaced |
| AUDIO · pending/ | Recordings waiting for the cron | Deleted the moment a transcript exists |
| AUDIO · failed/ | Jobs that failed for 45 minutes | Until somebody clears them by hand |
| AUDIO · text/ | One transcript per meeting, for /retask | Kept. A few kB each. |
| AUDIO · labels/ | Label jobs waiting for the Pi | Seconds, while the Pi is up |
| AUDIO · labels-claimed/ | Taken by the Pi, not yet confirmed | Re-queued after 10 min if never confirmed |
| AUDIO · labels-failed/ | The printer gave up | Until somebody clears them |
| AUDIO · labels-meta/heartbeat | When the Pi last polled | Rewritten at most once a minute |
R2 operations through a binding are not subrequests and do not count against the 50-per-invocation ceiling. That is why the minutes pipeline can move whole files around freely while being careful about every Notion write.
Budgets
| Budget | Limit | In use | Notes |
|---|---|---|---|
| Worker requests | 100,000/day | ~250 | A 250-person club would need 400 commands each to reach it |
| Worker CPU | 10 ms/request | ~1–3 ms | Network waiting does not count. This is why nothing base64-encodes a large file. |
| Subrequests | 50 per invocation | 3–34 | The real ceiling. Every batch size in the code is set by it. |
ctx.waitUntil | 30 s after the reply | — | Anything longer must move to the cron. This is why /notes is a queue. |
| Cron invocation | 15 min | ~1–3 min | Where the slow work lives |
| KV writes | 1,000/day | ~290 | Two caches on a 10-minute cron |
| KV reads | 100,000/day | ~500 | |
| Cron triggers | 5 | 1 | Multiplexed; effectively uncapped |
| R2 storage | 10 GB | <100 MB | No egress charge, ever. Binding calls are not subrequests. |
| Notion API | ~3 req/s | 6 per 10 min | Reads never touch it — the cache absorbs them |
| Groq free tier | 25 MB/file · 6,000 TPM | — | The token limit is why Gemini writes the minutes |
| Gemini free tier | 1M context · 1M TPM | ~20k/meeting | Reads a 90-minute meeting in one call |
The minutes job is the tight one
Every other path uses a handful of subrequests. This one adds up, so it is worth keeping in view:
| Step | Subrequests |
|---|---|
| Transcription | 1 |
| Summarising (chunks + final + one-liner) | up to 3 |
| Task extraction | 1 |
| Projects query, for the card selects | 1 |
| Minutes page + block appends | 3 |
Task rows (MAX_MINUTE_TASKS) | up to 12 |
Assignment cards (MAX_ASSIGN_CARDS) | up to 8 |
| The minutes embed | 1 |
| Worst case | 30 |
Raise either constant and re-do that sum. The margin above 30 is what absorbs retries. A single careless getIndex() in this path once added six to ten more, because a cold index rebuild reads the whole inventory and the entire append-only ledger — which is exactly the kind of call that is free in a command and reckless here. The lighter projectsOnly() query exists for that reason.
Other batch limits set by the same ceiling: 15 overdue DMs per sweep, 6 retention DMs per run, 5 new meetings per sync, 20 requests closed per button press, 40 rows per backfill pass, 1 minutes job per cron firing. Anything above the cap rolls to the next firing rather than failing.
Gotchas
The things that will bite whoever inherits this. Most were learned by being bitten.
Platform constraints that cannot be worked around
- The bot cannot see events. No member joins, no messages, no reactions, no voice — those need a gateway connection a Worker cannot hold. Everything starts with a command, a button, or the clock. Where an event is wanted, the pattern is a pinned message with a button. This is also why the bot cannot sit in a voice channel and record a meeting itself — somebody records and hands the file to
/notes. - Three seconds to reply or Discord shows "the application did not respond." Anything slower acknowledges first and edits its own reply.
ctx.waitUntil()is capped at 30 seconds after the reply, and a Worker killed at that cap reports nothing — indistinguishable from a stall. Long work belongs on the cron.- Interaction tokens die after 15 minutes. This is why approvals post a new message rather than editing the original request — a decision usually takes longer than that.
- Guild commands can never appear in DMs. Not a setting. See §06.
- A DM interaction has
userbut nomember, so every role check fails unless the member is looked up. The Worker does this automatically. - Notion file URLs expire in about an hour; Discord CDN links in about a day. Neither can be stored. This is the entire reason R2 exists here.
- Formula and rollup values are unreadable through the Notion API. See §02.
- DMs are opt-out. A member can block server-bot DMs and the send simply fails. Every DM path has a channel-mention fallback.
- Discord limits: 5 buttons per row, 5 rows per message, 25 options per select, 100 characters per
custom_id. The project picker slices to 24 plus "No project" for exactly this reason.
The printer is the only thing that is not serverless
A Pi in a cupboard is a machine somebody has to keep alive. It is deliberately as dumb as possible: no state, no inbound connection, restart-on-crash, and a queue that lives on the Worker, so the Pi being off for a week costs nothing but a week of labels waiting. /health shows when it last checked in. If it dies for good, any Linux box with Python and the printer plugged in replaces it in ten minutes.
Discord gives you three names
And only one is the one members recognise:
@handle. An implementation detail most people never think about again.The trap: when somebody is picked in a command option, the nickname is in resolved.members[id].nick, not resolved.users. Code that only reads resolved.users cannot reach the nickname and falls through to the handle every time — silently, and then writes it into Notion where it stays. Everything now goes through memberName(), actorName() and pickedName(); use those rather than reaching into the interaction by hand.
Verifying a change before deploying
node --check worker.js is not a real check. Node parses a .js file as CommonJS, where module-only syntax — a top-level await, an export — is a different error class that it will happily miss. The file is deployed as an ES module. Check it as one:
cp worker.js /tmp/x.mjs && node --check /tmp/x.mjs
An await inside a non-async function once passed the wrong check and reached production.
Local maintenance
TZ_OFFSETis manual. Cloudflare has no timezone awareness. Set it to-5on 1 November and-4in March, or every scheduled job drifts an hour.- Synced meetings default to
Kind: General. If socials run as Discord events too, retag them — otherwise skipping two socials reads as lapsing. - Meeting sync is create-only. Move a Discord event after its Notion row exists and the row keeps the old time. Deliberate: two-way sync would silently overwrite hand edits.
Due Backis never set by/checkout. The daily overdue sweep therefore has nothing to find until dates are set by hand. Adding adue:option is a small change.- Task nagging needs both an owner and a due date. A task with one and not the other is never chased. This is the most common reason somebody says "the bot didn't remind me."
- Flags do not block anything. A part marked Broken still checks out. Enforcing it is one line, but a rule nobody maintains just teaches people to route around the bot.
- Old Notion rows keep whatever name was current when they were written. Anything with a Discord ID renders as a live mention and self-corrects; plain stored strings do not.
/img/URLs are public to anyone holding them — necessarily, since Discord fetches them to render embeds.
Runbook
Deploying a change
- Check the file as a module:
cp worker.js /tmp/x.mjs && node --check /tmp/x.mjs - Worker → Edit code → select all → paste → Deploy.
- Added or changed a variable or binding? Deploy again — they do not apply until you do.
- Added, removed or renamed a command or one of its options? Visit
/register?secret=…. - Added a Notion database? Connect it to the integration, or every call to it 404s.
- Open
/healthand read it.
Diagnosing
NOTION_TOKEN is wrong or was pasted with whitespace./register was not run, DISCORD_GUILD_ID is wrong, or the bot was invited without applications.commands./register.DISCORD_PUBLIC_KEY mismatch, nine times in ten./refresh forces it and shows what changed./health reports minutes_queued. If it is climbing, the cron is failing — check the errors channel. If it is zero and nothing was posted, the job moved to failed/ and said so.GEMINI_TEXT_MODEL by hand./retask) or extraction fell back to the old scrape.Undoing things
Delete the ledger row. Counts are derived, so they follow immediately — there is nothing else to unwind. The same is true of attendance rows and bring requests. Never fix a count by editing a number.
Handover
Five things must survive the person who built this:
- The Discord application should be owned by a Team, not a personal account.
- Cloudflare and Notion should be on the club address, not a student one that expires at graduation.
worker.jsbelongs in the club GitHub, with this document beside it.REGISTER_SECRET, the bot token, and the model API keys need to be somewhere the next president can reach.- The model API keys are the one recurring cost risk — both are free tiers on personal accounts today. Moving them to club accounts is worth doing before the handover, not during it.
Not built
Designed, understood, deliberately not done.
Next, if anything
- Live distributor pricing. Mouser is a plain API key; DigiKey is OAuth2 with roughly ten-minute tokens, so it needs a KV token cache. The payoff is price breaks, stock and lead time on
/item, plus a/sourcepicker that confirms a match once and pins it forever — the same confirm-once principle as the assignment board. It reaches perhaps 40% of the catalogue; the industrial parts have no public API and no public price and never will. - QR check-in. A browser request carries no Discord identity, so a scan only knows who scanned via Discord OAuth
identify— one authorise tap, silent thereafter. Would write to the same Attendance table as the button. - QR redirect through the Worker. Would let every printed label be re-pointed without reprinting. Worth deciding before the next label print run.
- Public catalogue page. Cheap now, since photos already have permanent URLs.
/joinand role self-select. Replaces the join event the bot cannot receive.- Certification-gated checkout. Turns a safety policy into something the software enforces.
Not code problems
Roughly 30 inventory rows have a part number that is a sentence rather than a number — CM3+ (variant to confirm), 621772 (motor) / V692821-1.1 (gearhead), Cast marking '402 666 24V'. Another 54 have no location. No amount of software fixes these; they need somebody standing at the shelf with the part in their hand.