← Coverage hub REPS WITH FRIENDS Β· DOCUMENTS v1.1.0

RWF Backend Chat Architecture β€” the single source of truth

Date: 2026-08-31 Β· Status: living document (supersedes the transport sections of docs/03; builds on docs/21) Founder question this answers: "figure out how the tech backend of this should work so it is through slack or whatsapp etc."


TL;DR β€” the architecture in six lines

  1. ONE game core (@rwf/game-core): pure engine β€” handicap scoring, comeback, seasons. No I/O, no platform code.
  2. ONE chat brain (@rwf/bot-core CommandBus): every command (new/join/start/log/s/taunt/pot/result/rematch/nemesis/digest/season/watch/challenge/link/help) enters here, regardless of platform.
  3. MANY dumb transports (implement ChatTransport in packages/bot-core/src/transports/): Slack Bolt (official) Β· WhatsApp via Qalarc Hub now β†’ Cloud API at pilot Β· Beeper Desktop (new, built) Β· Telegram via bridge. Transports translate events in and cards out β€” nothing else.
  4. ONE state authority (apps/api on :4174, JSON file now β†’ Postgres later): crews, matches, seasons, pots. Bots mirror into it (MatchStore.api() β€” landed); the app pulls from it (already built).
  5. The app is a first-class client of the same API β€” same scoreboard the bots write to, same crew codes (link CREW-XXXX binds a chat to the crew the app created).
  6. Official APIs at scale, bridges for reach (docs/21): never build stranger-facing product on unofficial sessions; bridges (Beeper/Matrix) are for founder/friends/community groups.
                        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚                    HUMANS                              β”‚
                        β”‚  Slack   WhatsApp   Telegram   Discord   …   Web app    β”‚
                        β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜
                             β”‚         β”‚         β”‚         β”‚               β”‚
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β” β”Œβ”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”          β”‚
                 β”‚ Slack Bolt   β”‚ β”‚ Qalarc   β”‚ β”‚ BEEPER DESKTOPβ”‚          β”‚
                 β”‚ (official)   β”‚ β”‚ Hub β†’WA  β”‚ β”‚ local API+WS  β”‚          β”‚
                 β”‚              β”‚ β”‚ (Cloud   β”‚ β”‚ one transport,β”‚          β”‚
                 β”‚              β”‚ β”‚ API@pilotβ”‚ β”‚ N networks)   β”‚          β”‚
                 β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β”‚
                        β”‚              β”‚              β”‚                   β”‚
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”‚
        β”‚        @rwf/bot-core  β€”  THE SINGLE CHAT BRAIN          β”‚        β”‚
        β”‚  ChatTransport (dumb pipes)  Β·  CommandBus (grammar)    β”‚        β”‚
        β”‚  cards / digest / taunts / AI hooks                     β”‚        β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β”‚
                                β”‚  MatchStore  ──.api() mirror (P1, landed)
                                β”‚  file primary Β· apps/api when reachable
                                β–Ό                                           β–Ό
        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”        β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
        β”‚  apps/api  :4174  β€”  STATE AUTHORITY      │◀───────│ apps/web (PWA)           β”‚
        β”‚  crews Β· matches Β· seasons Β· pots         β”‚  pull/  β”‚ localStorage + outbox    β”‚
        β”‚  JSON file now β†’ Postgres at scale        β”‚  sync   β”‚ sync layer (built)       β”‚
        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                        β”Œβ”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”
                        β”‚ @rwf/game-core β”‚  pure engine: handicap, comeback,
                        β”‚ (no I/O)       β”‚  seasons, photo-finish β€” used by ALL of the above
                        β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

1. Component contracts (what each piece is allowed to do)

ComponentPathContract
game-corepackages/game-core/src/Pure functions over MatchState. Never touches network, files, or platforms.
CommandBuspackages/bot-core/src/bus.tshandle(InboundMessage) β†’ card text. Platform-agnostic (chatId/playerId are prefixed strings like wa:+614…, slack:U123, beeper:@tg_ben:beeper.com). Never throws β€” errors come back as cards.
MatchStorepackages/bot-core/src/store.tschatId β†’ StoredMatch (+ seasons/spectators/challenges). File-primary. .api(baseUrl) turns on the apps/api mirror (P1, landed) β€” see Β§5.
ChatTransportpackages/bot-core/src/transports/chat-transport.tssend(text, to) + health() (+ start/stop for receiving). The dumb-pipe rules: no game logic, never block, stay quiet on chatter, rate-limit personal-account sends.
apps/apiapps/api/src/The state authority: POST/GET /crews, /matches/:id/log, /mvp, /season, POST /bots/state (bot mirror). JSON file store now; Postgres when a second writer appears.
app sync layerapps/web/src/sync.tsOffline-first mirror of app actions β†’ apps/api, with outbox + crew pull. Already built; unifying is a matter of pointing it at a deployed API (T5, Β§6).

Identity model (v1, pre-auth): a player is a per-platform id string. Cross-platform player merging (same human on WhatsApp + Telegram) is future work via invite links β€” do not silently merge. Crew codes are the join key today: a chat runs link CREW-7Q2 and its matches surface under that crew everywhere (app + API + other chats' watch CREW-7Q2).


2. Data flow β€” worked example

A rep logged in a WhatsApp group flows everywhere it needs to go:

 1. Dave types "log pushups 80"  in the WhatsApp group (Qalarc Hub session)
 2. Hub writes the inbound line to messages.jsonl β†’ bot-whatsapp tails it (2s poll)
 3. looksLikeCommand("log pushups 80") βœ“ β†’ CommandBus.handleAsync({chatId:"wa:group-…",
    playerId:"wa:+614…", playerName:"Dave"})
 4. Bus: resolve exercise β†’ applyComeback β†’ game-core logReps β†’ MatchStore.update()
    β†’ persist() writes .data/bot-matches.json  AND schedules the apps/api mirror push
 5. apps/api (POST /bots/state) upserts the crew-linked match under CREW-7Q2
    β†’ GET /crews/CREW-7Q2 (the app's existing pull) now shows Dave's reps
 6. Bus returns the log card β†’ bot-whatsapp sends it via Hub β†’ WhatsApp group
 7. (broadcast fan-out, P1.5 β€” seam exists, wiring TODO) every chat that ran
    `watch CREW-7Q2` gets the standings milestone card β€” including a Telegram
    group reachable through the Beeper transport, and the app's crew screen
    on its next pull
 8. Match closes β†’ result card + SVG β†’ season ladder records it

Step 7 is the only piece not wired end-to-end today: the MatchStore already tracks spectators per crew (spectators map), but there is no cross-transport broadcaster. TODO (P1.5): add a BusEvents.onMatchClosed hook in bot-core and a small fan-out loop in each bot main. Design note: broadcasts must be batched (milestones/hourly), never per-rep β€” cost control (docs/03 Β§1).


3. Transport inventory & status

TransportPathStatusReachToS posture
Slack Boltapps/bot-slack/skeleton (T1: needs 5-min app setup)corporate workspacesβœ… official
WhatsApp β€” Qalarc Hubapps/bot-whatsapp/live (dogfood)founder's groups/DMs⚠️ personal session (grey)
WhatsApp β€” Cloud APIplanned (same ChatTransport seam)not started (T2: group support unverified)customers at scaleβœ… official, per-convo $
Beeper Desktoppackages/bot-core/src/transports/beeper.tsbuilt + sim-green; live blocked on Beeper being open (Β§7)every network the founder connects in Beeper: WhatsApp, Telegram, Discord, Signal, Messenger, LinkedIn, X, β€¦βš οΈ personal sessions via on-device bridges (grey) β€” dogfood only
Telegram Bot APIP4not startedclean bot pathβœ… official (via Beeper bridge until then)
Matrix bot coreP3 (docs/21 option B/C)design onlyall bridged networks with ONE bot accountbridges grey; Matrix itself clean

The Beeper insight (docs/21): because Beeper is a Matrix client whose bridges are all open source, "add a platform" becomes "connect a bridge," not "write a bot." The BeeperDesktopTransport is the cheapest possible proof of that β€” one transport, every network the founder has connected, zero new infrastructure.


4. ToS / risk table & cost model

TransportAccount typeBan risk at dogfood volumeBan risk at scaleCostWhat breaks first
Slack Boltorg appnonenonefree tier β†’ per-active-user $Slack rate limits (~1 msg/s/burst)
WhatsApp via Hubpersonal linked devicelow but realunacceptable β€” never scale this$0Meta device-pair bans; Hub is one session
WhatsApp Cloud APIbusiness numbernonenoneper-conversation (template/utility/marketing tiers) β€” model before pilotcost at chatty-group volume; group support unverified (T2)
Beeper Desktopfounder's personal accounts (all networks)low at ≀ a few msgs/min (Beeper's own warning: "sending too many messages might result in account suspension")unacceptable$0 (≀5 accounts; Plus $9.99/mo beyond)desktop must stay running; per-network rate limits; port may change per install
Telegram via bridgepersonal MTProto sessionlowgrey$0session limits
Telegram Bot APIbotnonenonefreeprivacy-mode/group-admin config
Matrix (P3)bot accountnone (Matrix)bridge sessions still grey per network$0–10/mo (bbctl self-host free)homeserver ops (if self-hosted)

Operating rules that fall out of this table:


5. Migration plan β€” each phase shippable

P0 β€” where we were this morning βœ…

Transports write their own stores (bot-matches.json), app is standalone localStorage. Works, splits state.

P1 β€” bots mirror into apps/api βœ… LANDED 2026-08-31

P2 β€” app onto the same authority 🟧 mostly built

apps/web/src/sync.ts already mirrors app actions β†’ apps/api (offline-first, outbox, armed opt-in) and pulls crews (GET /crews/:code). Remaining:

  1. Deploy apps/api somewhere always-on (minirig once it's back β€” T3 β€” or a $5 VPS / Cloudflare worker) so sync is available beyond localhost.
  2. Flip the app's crew-adopt path to prefer API ids when present (id mapping already exists in rwf.sync.v1 meta).
  3. Result cards generated by bots (SVG in .data/cards/) need a URL the app can render β€” serve cards/ through apps/api (static route) or upload to the same host.

P3 — Matrix as the canonical bot transport (docs/21 option B→C)

Run @rwf/bot-core as a Matrix user (@rwf:beeper.com first, our own homeserver later β€” Tuwunel/Continuwuity, not conduwuit). Per-platform reach = which bridges are connected (bbctl self-hosting for the noisy ones, free). The ChatTransport seam already fits: a MatrixTransport is a receiving transport whose chatIds ARE Matrix room ids β€” the Beeper transport's beeper:!room:… ids are already Matrix-shaped, so migration is renames, not rewrites. Exit criteria: bot posts to a bridged Telegram group without the founder's Beeper running.

P4 β€” official APIs at scale

WhatsApp Business Cloud API (Meta verification, per-convo cost model, template messages, DM-fallback design if groups are out β€” T2), Telegram Bot API, Slack stays Bolt. Bridges remain for the long tail (Signal, iMessage). Exit criteria: a stranger can join a paid crew on an official-API platform with a supportable ToS posture.


6. T5 (app ↔ bot state split) β€” concrete unblock steps

The blocker (docs/15): app localStorage vs bot .data JSON, no sync; a crew created in the app can't be played by the bots. P1 just closed the hard half. Remaining, in order:

  1. Wire the bots' stores to the mirror (P1 TODO above β€” one line per bot main; bots then WRITE to the authority).
  2. Always-on apps/api (T3): minirig systemd user service (pattern exists: scripts/hosting/) or VPS; CORS already allows rwf.qalarc.com.
  3. App reads through the API when armed: crew screen pulls GET /crews/:code (built) β€” bot matches appear because of P1's adoption.
  4. Shared crew code UX: app shows the crew code prominently (it already mints 5-char codes, same alphabet as bot link); bot link <CODE> binds the chat. No new id system needed.
  5. Identity (T4) later: magic-link auth via Resend unifies a human's platform ids under one account; until then, per-platform ids + crew codes are honest and work.

7. The Beeper transport (built today) β€” operator's guide

Code: packages/bot-core/src/transports/beeper.ts (+ beeper-cli.ts harness, mock-beeper.ts sim server, test/beeper.test.ts — 21 tests). Implements: ChatTransport + receive (start/stop) → WS /v1/ws with subscriptions.set ["*"]; REST GET /v1/chats, POST /v1/chats/{id}/messages; Bearer auth; echo suppression via isSender; chatter filtered by looksLikeCommand; replies upgraded mrkdwn→markdown (*x*→**x**) for Beeper rich text; sends rate-limited (minSendGapMs, default 1.2s — personal-account safety). Crew mapping: every chat the bot speaks in is recorded in .data/beeper-links.json (id + network label); link <CODE> flows bus → CrewLinkMap; chatsFor(crewCode) is the fan-out join point for P1.5 broadcast.

Probe findings on this machine (2026-08-31):

To go live (founder, ~3 minutes):

# 1. Open the Beeper Desktop app (leave it running) and connect β‰₯1 network
#    (e.g. Telegram) with a group you're willing to dogfood in.
# 2. Beeper β†’ Settings β†’ Integrations β†’ β€œ+” next to β€œApproved connections”
#    β†’ create an access token.
# 3. export BEEPER_ACCESS_TOKEN=<that token>
# 4. bun packages/bot-core/src/transports/beeper-cli.ts --probe   # expect up βœ“ authed βœ“
# 5. bun packages/bot-core/src/transports/beeper-cli.ts --live    # say `rwf help` in the group

Sim needs none of this: bun packages/bot-core/src/transports/beeper-cli.ts --sim runs the whole loop (probe β†’ chats β†’ link β†’ match β†’ photo-finish result β†’ cross-network spectators) against the in-process mock.

Known caveats: the API port is chosen by the app (23373 today; the probe re-discovers it from logs); --live drives the founder's personal accounts β€” keep volume conversational; iMessage impossible off-macOS; message history may be partial until Beeper finishes indexing a newly-added account.


8. What is intentionally NOT in this architecture (yet)

9. Cross-references