BU Robotics Club · technical reference

Bunker Bot Reference

Everything the club automates, how it is wired, and the specific things that will break it. One Discord application, one Cloudflare Worker, eleven Notion databases, and no server.

worker.js · ~5,500 lines · zero dependencies · 37 slash commands · $0/month

01

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.

02

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

NumberMeansMoved by
On HandWhat the club physically holdsOut subtracts · Return, Restock, Adjust add
ReservedCommitted to projects. Still club property, just spoken for.Allocate adds · Release subtracts
AvailableOn 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.

03

Notion databases

Eleven data sources. The IDs are what the Worker's variables point at.

DatabaseVariableData source IDHolds
Bunker InventoryINVENTORY_DS37c50591-221f-80c8-ae9d-000bebcea235136 items, serials, locations, tiers, photos, flags, supplier fields
Bunker Checkout LogLEDGER_DSd900e4a9-df58-4de8-baa0-60c389fdaf6dEvery movement, ever. Append-only.
ProjectsPROJECTS_DSfd8587db-eafe-4dd3-99cf-a5d1d688bbb1Agrobot, ARCTOS, Exoglove, Bunker infrastructure
MeetingsMEETINGS_DSd9f5fa89-e8de-4e20-b5e0-83b69f912ca2The calendar. Self-populating from Discord events. Minutes land here.
MembersMEMBERS_DSe935e4bb-cbc6-4a92-a2f1-e086bec48a55The roster. Self-populating from check-ins.
AttendanceATTENDANCE_DS9c1d2b64-66de-4963-a69f-8c8dc37051baOne row per person per meeting
TasksTASKS_DSf5140aa3-138a-4f57-adf7-d89c961bad4fWho owes what, blocks, due dates, project links
Purchase RequestsPURCHASES_DS2cb13198-de4b-4abf-86fc-295d3da631d2What the club wants to buy, and where each request got to
Bring RequestsREQUESTS_DScee8c7c0-cf01-4e28-b153-a8e62a2c96b0The pre-meeting pick list
Nudge TemplatesTEMPLATES_DS18970188-f9bd-404b-bf2a-a999b760e00bThe words the retention DMs use
Member FeedbackFEEDBACK_DS9c7027ce-591e-433c-adf5-a4284285f3f3Why 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 MsgchannelId|messageId of 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.

04

HTTP routes

RouteMethodAuthPurpose
/POSTEd25519 signatureThe Discord interactions endpoint. Everything interactive arrives here.
/healthGETnoneConfig 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.
/registerGET?secret=Pushes the command list to the guild. Add &scope=global to publish for DMs instead — see §06.
/refreshGET?secret=Forces a cache rebuild. /refresh in Discord does the same thing with a diff.
/backfillGET?secret=Splits prose part numbers into structured supplier fields. Dry by default; &dry=0 applies, &start=N resumes. See §09.
/img/<key>GETnone, publicServes an item photo from R2, immutable, cached for a year.
/i/<serial>GETnone, publicThe permanent address a printed label's QR encodes. 302 to the item's Notion page. Any spelling of the serial.
/labels/next · done · failedGET · POST?secret=PRINT_SECRETThe 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.

05

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

CommandAccessDoes
/findallSearches name, part number and keywords. load cell returns the FX29 cells and the load-cell amplifiers.
/browseallNine sorts — stock, price, total value, most allocated, needs restocking — filterable by category and tier, paginated.
/itemallFull card: three counts, reorder point, location, tier, value, photo, sourcing, any flag.
/statsallTotals and what needs attention.
/helpallReads the live command list; hides E-Board commands from members.
/refreshE-BoardPulls from Notion now and reports a before/after diff. Skips the call if the index is under 30 s old.

Stock movement

CommandAccessDoes
/checkoutE-BoardBorrow. Refuses more than Available. Approval-tier items route to the approvals channel and move nothing until decided.
/returnallClose out a borrow. The picker offers only what you are holding, and the quantity is capped at your outstanding balance.
/restockE-BoardLog a shipment arriving.
/allocateE-BoardCommit hardware to a project. Moves Available into Reserved; On Hand does not change.
/releaseE-BoardGive allocated hardware back to the shared pool.
/mineallYour tab: what you hold, what is overdue, what is awaiting approval.
/reportallFlag broken / missing / miscounted / needs label. Posts a card with a resolve button.
/bringallAsk for something to be carried to the next meeting. Creates no ledger movement.
/requestsE-BoardThe pick list, grouped by shelf.
/intakeE-BoardCreate an item and get its permanent serial back. Optional photo. Auto-flags Needs label if no location was given.
/photoE-BoardAdd or replace an item photo. Deletes the old object.
/labelE-BoardQueue a label for the printer in the bunker. /intake … label:True does the same for a new part.

Projects

CommandAccessDoes
/projectallSummary, lead, status, dates, value held, biggest allocations.
/resourcesallEvery line allocated to one project, paginated.

Work

CommandAccessDoes
/taskE-BoardAssign work directly, with an optional due date, priority, area and project.
/tasksall
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.
/doneallClose a task. Yours, or anyone's if you are a lead.
/blockallSay who you are waiting on and what you need. Transfers the chasing to them.
/unblockallClear a block. The blocker, the owner, or a lead can do it; the owner is told they can carry on.
/retaskE-BoardRe-read a meeting's transcript and propose its tasks again. Bins unassigned proposals, keeps anything somebody owns.

Buying

CommandAccessDoes
/requestallAsk the club to buy something. Files as Pending and posts an approve/deny card.
/ordersE-BoardEvery approved request grouped by supplier, as paste-ready part,qty lists.

Meetings and attendance

CommandAccessDoes
/hereallCheck in. Only works while a meeting is open; once per person per meeting.
/attendanceall
E-Board for others
Emoji month grid plus attended, rate, current and best streak.
/rollE-BoardWho is checked in right now, and when check-in shuts.
/eventE-BoardCreates the Discord scheduled event and the Notion row. Reads tomorrow 6pm, friday 18:30, 9/18 5pm.
/meetingE-BoardOpens an ad-hoc meeting immediately, no calendar entry.
/checkin-buttonE-BoardPosts the permanent check-in button. Run once, ever, then pin it.
/notesE-BoardHand in a recording. Transcribed, summarised and filed on the meeting's Notion page; tasks proposed from it. See §07.
06

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.

PrefixShapeAccessDoes
pg|pg|mode|arg|pageallPrev/Next on any list
ap|ap|pageId|y·nE-BoardApprove or deny a pending stock request
fx|fx|pageIdE-BoardClear an item's flag
hi|hi|nowallCheck in; resolves the open meeting at press time
bx|bx|allE-BoardMark every open bring-request as brought
wl|wl|reasonIndexallExit-survey answer
ta|ta|taskIdE-BoardMember select — assigns a proposed task
tp|tp|taskIdE-BoardProject select — files a task under a project, or clears it
td|td|taskIdE-BoardOpens the due-date form
tx|tx|taskIdE-BoardNot a task — drops a proposal
kd|kd|taskIdowner or leadDone, from a DM or a digest
ku|ku|taskIdblocker, owner or leadUnblock, from a DM or a digest
pq|pq|pageId|y·nE-BoardApprove or deny a purchase request
pm|pm|supplierE-BoardMark every approved line for one supplier as ordered
hw|modalallCheck-in word prompt
wo|modalallExit-survey free text
tdm|modalE-BoardDue-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.

07

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

StepProviderWhy
TranscriptionGroq Whisper, else Workers AIGroq 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 tasksGemini, else Groq, else Workers AIGemini 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

404 — model retired
Permanent. The Worker queries the live model list, ranks what is there, retries, and persists the new choice to KV so it does not rediscover it every time.
503 — model overloaded
Transient. It waits out the same model, and if it must borrow another it does so for that one call and never persists the swap. A busy minute is not a reason to move the club to a different model permanently.

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.

08

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:

FieldIs
whatImperative and concrete, with the specifics the transcript gave. "Buy candy at Walmart with the $75 Ignite budget", not "Handle refreshments".
basisstated — somebody said they would. implied — it follows from a decision and nobody volunteered.
whyThe decision it came from, so a reviewer can sanity-check it at a glance.
dueOnly 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.

09

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.

RuleExampleGives
digikey+mpn497-VL53L3CXV0DH/1CT-ND (MPN VL53L3CXV0DH/1)Supplier, SKU and MPN
digikeyMAX11206EEE+-NDSupplier 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 onlyADC121S021 (MPN ADC121S021CIMF/NOPB)MPN, no supplier claimed
bareHRPG-1000-12MPN, 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.

10

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)JobGoes to
every 10 minRebuild the catalogue cache; announce approvals made in Notionlog channel
every 10 minRebuild the attendance cache
every 10 minMinutes queue — one job per run: transcribe, summarise, file, propose tasksminutes + assignments
every 10 minPick list check for meetings coming upapprovals
daily 08:00Task digest — DMs each owner what they owe, tells leads what is rottingDM, then tasks channel
daily 09:00Overdue sweep — DMs borrowers, escalates at 3 days or if the DM bouncesDM, then approvals
every 6 hMeeting sync from Discord scheduled events (max 5 new per run)
T−2 hPick list for each upcoming meeting, grouped by shelf. Fires once per meeting.approvals
Wed 17:00Retention nudges — max 6 per runDM, summary to nudge channel
Sun 19:00Restock digest, priced, with a refill totalapprovals
Sun 20:00Inventory-gaps digest — missing name, location, count, price, photo; flagged rowsapprovals

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.

CronFirings/dayKV writes/dayVerdict
*/10144~290Current. Comfortable.
*/5288~580Fine, with headroom for interactive writes.
*/2720~1,440Over budget.
*/11,440~2,880Nearly 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 DMs is 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.

11

Configuration

Bindings

NameTypeWithout it
CACHEKV namespaceStill works, but calls Notion on every interaction. Will rate-limit under load.
IMGR2 bucketPhoto commands refuse with a clear message. Everything else is unaffected.
AUDIOR2 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.
LABELSR2 bucketOptional. The label queue uses it if bound, else AUDIO, else IMG.
AIWorkers AIOnly 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.

NameTypeIs
DISCORD_APP_IDtextApplication ID
DISCORD_PUBLIC_KEYtextVerifies every incoming signature
DISCORD_BOT_TOKENsecretPosting, DMs, events, member lookups
DISCORD_GUILD_IDtextCommand registration, event scope, and the nickname lookup for DM interactions
NOTION_TOKENsecretIntegration secret, ntn_…
REGISTER_SECRETsecretGuards /register, /refresh and /backfill
PRINT_SECRETsecretGuards 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_DStextSee §03
LEDGER_DStextSee §03
PROJECTS_DStextSee §03

Optional variables

Each feature is inert without its own variables and says so rather than failing.

NameDefaultControls
EBOARD_ROLE_IDEvery lead-only gate. Unset means nobody passes.
LOG_CHANNEL_IDPublic transaction log
APPROVALS_CHANNEL_IDApprovals, digests, alerts, pick lists
ERRORS_CHANNEL_IDThe bot's own exception reports
NUDGE_CHANNEL_IDapprovalsRetention summaries and survey answers
ASSIGN_CHANNEL_IDapprovalsWhere task proposals go to be assigned
TASKS_CHANNEL_IDassign → approvalsBlocked 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_IDlogWhere finished minutes are posted
MEETINGS_DSAttendance, all of it
MEMBERS_DSAttendance, all of it
ATTENDANCE_DSAttendance, all of it
TASKS_DSEvery task command, the digest, and extraction from minutes
PURCHASES_DS/request and /orders
REQUESTS_DS/bring and the pick list
TEMPLATES_DSbuilt-in copyEditable nudge wording
FEEDBACK_DSThe exit survey
GROQ_API_KEYTranscription, and summarising if Gemini is unset
GEMINI_API_KEYMinutes and task extraction. Strongly preferred — see §07.
GEMINI_TEXT_MODELauto-discoveredPins a model instead of letting the Worker pick and remember one
GROQ_STT_MODELwhisper-large-v3-turboTranscription model
GROQ_TEXT_MODELllama-3.3-70b-versatileSummarising, when Groq is the text provider
CONSENT_NOTEbuilt-in wordingThe provenance line written onto every set of minutes
PUBLIC_BASEThe Worker's own URL, for photo links. No trailing slash.
TZ_OFFSET-4Club local time. Set to -5 on 1 November.
CHECKIN_EARLY_MIN30How early check-in opens
CHECKIN_GRACE_MIN15Grace after a stated end time
CHECKIN_LATE_MIN120Window when no end time is known
STT_TIMEOUT_MS180000Transcription deadline
LLM_TIMEOUT_MS60000Per model call
TRANSCRIBE_BUDGET_MS300000Whole-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 bot and applications.commands at invite time.
12

Caches and object stores

KV

KeyHoldsRebuiltCleared by
index:v5Whole catalogue, projects, open borrows, pending requestscron, or on demandany stock write
attend:v1Meetings, members, attendance rows, every taskcroncheck-in, new meeting, any task write
decided:v1Approval row IDs already announcedcronnever
picked:v1Meetings whose pick list has gone outas needednever (last 200 kept)
gemini:modelThe model chosen after a 404 forced rediscoveryon a 404never (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 / prefixHoldsLifetime
IMGItem photos, served publicly at /img/<key>Until the item's photo is replaced
AUDIO · pending/Recordings waiting for the cronDeleted the moment a transcript exists
AUDIO · failed/Jobs that failed for 45 minutesUntil somebody clears them by hand
AUDIO · text/One transcript per meeting, for /retaskKept. A few kB each.
AUDIO · labels/Label jobs waiting for the PiSeconds, while the Pi is up
AUDIO · labels-claimed/Taken by the Pi, not yet confirmedRe-queued after 10 min if never confirmed
AUDIO · labels-failed/The printer gave upUntil somebody clears them
AUDIO · labels-meta/heartbeatWhen the Pi last polledRewritten 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.

13

Budgets

BudgetLimitIn useNotes
Worker requests100,000/day~250A 250-person club would need 400 commands each to reach it
Worker CPU10 ms/request~1–3 msNetwork waiting does not count. This is why nothing base64-encodes a large file.
Subrequests50 per invocation3–34The real ceiling. Every batch size in the code is set by it.
ctx.waitUntil30 s after the replyAnything longer must move to the cron. This is why /notes is a queue.
Cron invocation15 min~1–3 minWhere the slow work lives
KV writes1,000/day~290Two caches on a 10-minute cron
KV reads100,000/day~500
Cron triggers51Multiplexed; effectively uncapped
R2 storage10 GB<100 MBNo egress charge, ever. Binding calls are not subrequests.
Notion API~3 req/s6 per 10 minReads never touch it — the cache absorbs them
Groq free tier25 MB/file · 6,000 TPMThe token limit is why Gemini writes the minutes
Gemini free tier1M context · 1M TPM~20k/meetingReads 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:

StepSubrequests
Transcription1
Summarising (chunks + final + one-liner)up to 3
Task extraction1
Projects query, for the card selects1
Minutes page + block appends3
Task rows (MAX_MINUTE_TASKS)up to 12
Assignment cards (MAX_ASSIGN_CARDS)up to 8
The minutes embed1
Worst case30

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.

14

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 user but no member, 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:

member.nick
The server nickname. What they set here, what the member list shows, what a mention renders as. Use this.
user.global_name
The account's display name. A reasonable fallback.
user.username
The @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_OFFSET is manual. Cloudflare has no timezone awareness. Set it to -5 on 1 November and -4 in 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 Back is never set by /checkout. The daily overdue sweep therefore has nothing to find until dates are set by hand. Adding a due: 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.
15

Runbook

Deploying a change

  1. Check the file as a module: cp worker.js /tmp/x.mjs && node --check /tmp/x.mjs
  2. Worker → Edit code → select all → paste → Deploy.
  3. Added or changed a variable or binding? Deploy again — they do not apply until you do.
  4. Added, removed or renamed a command or one of its options? Visit /register?secret=….
  5. Added a Notion database? Connect it to the integration, or every call to it 404s.
  6. Open /health and read it.

Diagnosing

1101 in the browser
The Worker threw before its own error handling. Almost always a syntax error in a paste — re-paste the whole file.
Notion 404
That database is not connected to the integration. Notion's API cannot see unshared databases at all.
Notion 401
NOTION_TOKEN is wrong or was pasted with whitespace.
Commands missing
/register was not run, DISCORD_GUILD_ID is wrong, or the bot was invited without applications.commands.
New option missing
Adding an option to an existing command still needs /register.
"Did not respond"
Something threw. Check the errors channel first — it carries the command and the stack. Then Cloudflare → Logs → Live.
Endpoint won't save
DISCORD_PUBLIC_KEY mismatch, nine times in ten.
Counts look stale
Ten-minute cache. /refresh forces it and shows what changed.
Counts look wrong
Not staleness. Compare the Notion formula against the Worker's arithmetic — see §02.
Autocomplete empty
Errors surface as a ⚠️ choice rather than an empty list, so read what it says.
Minutes never arrive
/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 404
The model was retired. The Worker rediscovers and remembers a new one by itself; if it cannot, set GEMINI_TEXT_MODEL by hand.
Tasks not extracted
The minutes embed says why: either the meeting already had tasks (use /retask) or extraction fell back to the old scrape.
Nobody is being nagged
Chasing needs an owner and a due date. Check both.

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.js belongs 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.
16

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 /source picker 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.
  • /join and 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.