Garbanzo is a multi-platform community bot. Discord (official Gateway API) is the first-class default platform; WhatsApp (via the Baileys unofficial WhatsApp Web API) is fully supported and runs the original 120+ member Boston-area meetup deployment. Platform adapters sit behind a shared abstraction (src/core/ + src/platforms/), and instances compose via docker profiles. Messages route to AI models via a configurable cloud failover order (AI_PROVIDER_ORDER) plus local Ollama for simple queries.
The bot's persona is Garbanzo Bean 🫘 — a warm, direct, Boston-savvy community connector.
- Runtime: Node.js 20+ with TypeScript (ES Modules)
- WhatsApp:
@whiskeysockets/baileysv7 (multi-device, socket-based) - AI: Configurable cloud failover order (
AI_PROVIDER_ORDER) + Ollama (local for simple queries) - Validation: Zod for runtime type checking
- Logging: Pino
- Testing: Vitest
- Build:
tsc→dist/, dev viatsx watch - Default deployment: Docker Compose (
docker compose up -d)
Before implementing any feature, task, or utility, research whether a reliable, free, and trustworthy existing tool, library, or API already solves the problem. Only write custom code when no suitable option exists or when the existing options are unreliable, unmaintained, or introduce unacceptable dependencies.
Process for every new feature or task:
- Research first — Search for established open-source tools, npm packages, system utilities, or free APIs that address the need. Evaluate by: GitHub stars/maintenance activity, license (prefer MIT/Apache/BSD), dependency footprint, community trust.
- Evaluate fit — Does the tool cover 80%+ of the requirement? Is it actively maintained (commits in the last 6 months)? Does it have a reasonable dependency tree? Is it free for our use case?
- Propose before building — Present the option to the developer with a brief rationale (what it does, why it's better than hand-rolling, any tradeoffs). Get approval before adding dependencies.
- Fall back to custom only when justified — If no suitable tool exists, the options are abandoned/unmaintained, the dependency cost is too high, or the requirement is truly project-specific, then write custom code.
Examples of this principle in action:
- Secret scanning → gitleaks (MIT, 17k+ stars, 150+ detectors) instead of custom regex script
- WhatsApp API → Baileys instead of raw WebSocket implementation
- Schema validation → Zod instead of hand-written validators
- Logging → Pino instead of custom logger
- Speech-to-text → Whisper API (local Speaches server) instead of custom audio processing
- Text-to-speech → Piper (native binary) instead of custom synthesis
- YouTube download → yt-dlp instead of custom scraper
This principle applies equally to AI agents working on this codebase. When an agent is tasked with implementing something, it should research existing solutions before writing code. The agent should present options and let the developer choose.
All code changes are scanned for hardcoded secrets before they can be committed or pushed. This is enforced at three levels:
- Pre-commit hook —
gitleaks git --stagedruns automatically on everygit commit. Blocks commits containing API keys, tokens, private keys, personal emails, or other secrets/PII. Install hooks withnpm run setup:hooks. npm run check— The full pre-commit check pipeline (audit:secrets→typecheck→lint→test) includes a gitleaks scan of the working directory. Run this before every commit.npm run audit:secrets— Standalone secret scan. Use--verbosefor detailed findings,--stagedfor only staged files.
Configuration: .gitleaks.toml at project root. Built-in rules detect 150+ secret types. Custom rules added for WhatsApp JIDs and personal email addresses. Allowlists configured for files that legitimately reference patterns (docs, examples, config).
If gitleaks flags a finding:
- Move the secret to
.env(gitignored) - Reference via
process.env.VAR_NAMEin code - For test files, use fake values (
test_key_xxx,5550001234) - For genuine false positives, add an inline
gitleaks:allowcomment or update.gitleaks.tomlallowlist
Never commit personal email addresses, phone numbers, or private contact details to the repository. This rule is enforced by both the pii-personal-email gitleaks rule and the pre-commit hook.
- Public contact channels only — Use GitHub Issues, Discussions, or project-level email addresses for any contact links in websites, docs, or config files. Never use personal inboxes.
- The gitleaks
pii-personal-emailrule blocks commits containing emails from private providers (ProtonMail, Tutanota, iCloud, Fastmail, etc.). See.gitleaks.tomlfor the full list. - If you need to reference a maintainer — link to their GitHub profile, not their personal email.
- AI agents must never output, embed, or commit personal identifying information (names, emails, phone numbers, addresses) into any file unless the owner explicitly instructs them to do so in that specific conversation.
# Install dependencies
npm install
# Development (hot-reload)
npm run dev
# Interactive setup wizard (platform/provider order/models/features/persona/groups)
npm run setup
# Type-check without emitting
npm run typecheck
# Run linter
npm run lint
# Run tests
npm run test
# Scan for hardcoded secrets
npm run audit:secrets
# Install git hooks (pre-commit PII/secret scanning)
npm run setup:hooks
# Build for production
npm run build
# Start production
npm run start
# Full pre-commit check (secrets + typecheck + lint + test)
npm run check
# Release dry-run validation before tagging
npm run release:plan
# GitHub account workflow helpers
npm run gh:status
npm run gh:dependabot
npm run gh:switch:author
npm run gh:switch:owner
npm run gh:whoami
# Rotate GitHub Actions secrets from local env vars
npm run rotate:gh-secretsFile-level layout changes often — trust the directory purposes, explore the live tree, and use the Decisions Log below as authoritative context.
src/core/ - platform-agnostic inbound and outbound message pipeline.
src/platforms/<name>/ - one adapter plus runtime per platform.
src/bridge/ - cross-instance relay: capture to outbox to transport to deliver.
src/ai/ - AI routing, provider integrations, persona loading, and tools.
src/features/ - feature modules, usually one file per feature.
src/middleware/ - health, admin, rate-limit, context, retry, and request safety layers.
src/utils/ - shared persistence, formatting, RAG/vector, embedding, reranking, and support utilities.
src/utils/config/ - modular environment schema and config validation.
config/ - runtime JSON config families for Discord channels, bridge map, RAG sources, and WhatsApp groups.
docs/ - operator docs, architecture notes, runbooks, and personas.
deploy/helm/ - Kubernetes Helm deployment assets.
monitoring/ - Prometheus and Grafana stack.
tests/ - Vitest suites and prompt evals.
website/ - garbanzobot.com static site.
scripts/ - setup, release, audit, account, and operations helpers.
Settled questions — do not relitigate these; propose a change only with new evidence, and note it here when a decision changes.
- OpenAI is the primary AI provider (owner decision, 2026-06). Anthropic/Gemini/Bedrock/Ollama are failover via
AI_PROVIDER_ORDER. When an integration misbehaves, fix the integration — do not propose switching primary provider. - Production models:
gpt-5.4-miniprimary,claude-haiku-4-5fallback, prompt caching on (2026-07-01). - One process = one platform (
MESSAGING_PLATFORM). Discord is the first-class default platform through the official API; WhatsApp is fully supported through unofficial Baileys with documented account-risk safety; instances compose via platform profiles. - Discord runs a real discord.js Gateway (opt-in channels,
requireMentiondefault true); owner model =DISCORD_OWNER_ID(user id) + resolved DM channel for escalation; schedulers/welcome bound in the Discord runtime; WhatsApp login bootstrap is whatsapp-only. - Platform-named infra is the v2 model: compose profiles/services/jobs/env files are
discord,whatsapp, andmonitoring; persona names such as Remy do not name infrastructure. - Persona identity is file-driven:
getPersonaName()derives display identity from the loaded persona document; personas are configurable, and Garbanzo is the framework name. - Layered env files +
COMPOSE_PROFILESare the deployment model:.envholds shared values,.env.discordand.env.whatsapphold instance deltas, and Docker/native runs apply the same layering. - Remy band features are gated behind
BAND_FEATURES_ENABLED(default false → community/WhatsApp bot unaffected). The band knowledge base adds ONE structured table (songs: title/key/tempo/status[idea|rough|tight|gig-ready]/notes) — the keystone for practice/songwriting — while members/gear/decisions/gigs REUSE the existing memory+Qdrant fact pipeline (no new fact infra).!song(add/list/show/set/delete) routes through the shared group dispatch, gated on owner OR Discord band-member (roles plumbed from the gateway assenderRoleIds→isBandMember; core never imports discord-config). Read toolslist_band_songs/find_band_song+ a bounded catalog block in the system/Ollama prompt are all flag-gated. Deploy Remy as band mode on the Discord profile with.env.discord,BAND_FEATURES_ENABLED=true, and optionalremy_memory;npm run setupprovisions the split env files. - Remy practice features (sub-project 2, all
BAND_FEATURES_ENABLED-gated) add four tables following thesongs8-sync-point pattern:rehearsals(+!rehearsalschedule/list/show/cancel/note + a Discord 5-min reminder poller + optional weekly agenda auto-post),availability(+!available <id> yes|no|maybe— a STORED command, NOT a poll, because Discord'ssendPollis a text stub that can't capture votes; read back in!rehearsal show), andsetlists+setlist_songs(+!setlistcreate/add/remove/move/show referencing songs).!agendais a pure LLM-free builder (mirrorsbuildWeeklyRecap). AI toolsnext_rehearsal/current_setlistgated. sqlite runs WITHOUTPRAGMA foreign_keys=ON, so FKON DELETE CASCADEis inert — cascade cleanup is done IN CODE (deleteSong/deleteSetlistclearsetlist_songs); rehearsals are soft-cancelled, never hard-deleted. Follow-ups: availability read-back shows raw Discord IDs (needs display-name plumbing), scheduler binders gate onEVENT_REMINDERS_ENABLEDnot the band flag. - Remy songwriting features (sub-project 3, all
BAND_FEATURES_ENABLED-gated) addsong_ideas+song_sectionstables. Discord audio-attachment capture is greenfield here:InboundMessage.audio?: {url, contentType}is populated by the Discord gateway (firstaudio/*attachment by content-type or.m4a/.ogg/.mp3/.wav/.webmextension) and threaded through the dispatch — Discord previously discarded all attachments.!idea capturestores a song idea from text OR a dropped clip: itfetch()es the CDN url and transcribes via the existingtranscribeAudio(Whisper/Speaches atWHISPER_URL), storing the transcript + audio url. It degrades gracefully — if the Whisper server is unreachable / fetch fails / transcript is null, the idea is still stored (audio url set, transcript null); the audio path NEVER crashes the reply. We store the transcript + Discord CDN url, NOT raw audio bytes (no blob store).!idea promotecreates a song (status: 'idea') — the idea→demo→ready pipeline reusessongs.status, no new field.!section/!lyricsbuild per-song structure (kind/lyrics/chords →formatSongSheet, the Headchart seed). AI toolsget_song_sections/list_song_ideasgated.deleteSongalso clearssong_sections+ nullssong_ideas.song_idin code (sqlite FK inert). WHISPER_URL is the only new (optional) external dependency. Deferred: live voice-channel recording, blob storage, AI-generated lyrics. - Web search is multi-provider with priority Firecrawl → Brave → Google PSE → SearXNG (PRs #216, #220).
web_searchtool results get a 6,000-char budget vs 1,500 for other tools, to allow extracted page content. - The system prompt must explicitly direct models to prefer tools over training data (PR #218) — without it, models answer factual questions from stale memory. Preserve this directive in any prompt rewrite.
- Storage: SQLite is the default backend; Postgres exists behind
db-backend.ts(runbook:docs/POSTGRES_MIGRATION_RUNBOOK.md). - Vector memory: Qdrant is the single vector store (2026-07-03). Relational DB is source of record; all embeddings live in Qdrant (
garbanzo_memory). pgvector removed. Semantic search works in SQLite deployments.VECTOR_STORE=none= keyword-only. Embeddings: OpenAItext-embedding-3-small@ 1536; deterministic is tests/offline only and never mixed into a live collection. - WhatsApp anti-ban is load-bearing: Baileys 7.x + baileys-antiban, outbound safety rules in
docs/ADR-0001-whatsapp-outbound-safety.md, warm-up limitsday1Limit/maxPerDay= 2000. Never bypass the outbound-safety layer. - Moderation is human-in-the-loop: the bot warns in-group and DMs the owner; only the owner acts. Never auto-ban, never let members direct moderation.
- Git/GitHub: PRs only, the owner merges — agents never self-merge or bypass branch protection. Commits use the GitHub noreply email (
25596491+jjhickman@users.noreply.github.com); personal emails are blocked by the gitleaks PII rule. - Public-facing copy (README, website, Docker Hub) carries no AI-writing tells (PRs #212–#214): no em-dash chains, no "X, not Y" constructions, no model-name dropping; plain register.
- Releases: tagged versions publish images to GHCR + Docker Hub; production runs on a Raspberry Pi 5 via Docker Compose.
MONITORING_TOKENgates/metrics,/admin, Prometheus scrapes, and Grafana admin password fallback;WHATSAPP_LOGIN_TOKENgates only the WhatsApp login page. - Prompt changes are regression-tested against
tests/evals/prompt-eval-set.json(2026-07-03) — when changing PERSONA.md, persona.ts, or tools.ts, check the relevant eval categories before merging. - Cross-platform bridging (v3, 2026-07-06): bridge relays are delivered as direct messenger sends on the receiving instance, never re-injected as synthetic inbounds — loop prevention falls out of the existing self/bot-message drop. Receiver-side idempotency inserts the dedup key before attempting delivery and deletes it if delivery throws, so a sender retry of the same envelope is treated as fresh rather than silently dropped. A WhatsApp send that comes back
WhatsAppOutboundHeldErroris backpressure, not failure — the bridge folds that message into the route's summary buffer rather than blind-retrying. Capture sits inprocessInboundMessageright after sanitization + moderation, as a fire-and-forget enqueue. Bridge code must never callsendControlText(the anti-ban bypass). - Bridge groups (v3.3.0): a bridge route is N-ary (
endpointshas 2+ members). A source message fans out to every other endpoint in the group, with a target-scoped idempotency key (JSON-array encoding oforigin instance/chat/message + target instance/chat— injective even when ids contain:, as Matrix room/event ids do, so receiverbridge_seendedup never collapses sibling fan-out legs nor drops a real leg via a colliding key). Relays remain non-transitive because delivery is a direct send and is never re-captured. A route/group lists at most one endpoint per instance — a bridge member is one distinct instance (two WhatsApp numbers = twoINSTANCE_IDs, not one instance with two chats); run a second instance if you need two chats on the same platform. This keeps summary buffering and one-wayfromauthority unambiguous (each resolves an instance to exactly one endpoint). - Instance identity (v3):
INSTANCE_IDis deployment identity for bridging, shared-fact ids, and metrics, and defaults toMESSAGING_PLATFORMso existing deployments need no change. Running two instances of the same platform (for example two WhatsApp numbers) is a documented compose-copy pattern (new service name,INSTANCE_ID, env file, volumes, port), not a wizard flow. Persona names still never name infrastructure (the v2 directive stands) —INSTANCE_IDis a distinct, operator-chosen category, so a deployment can be namedremywhile its compose service stays nameddiscord. - Shared-memory privacy invariant (v3): nothing enters the shared Qdrant collection except a fact the owner explicitly ran
!memory share <id>on — no auto-sharing of conversation history, session summaries, or auto-extracted facts. Shared-fact ids are namespaced<INSTANCE_ID>:<localId>so numeric ids never collide across instances; a peer instance can retrieve a shared fact but only the origin instance can unshare it. - Dual bridge transport (v3): a durable, transport-agnostic per-instance SQLite outbox (
bridge_outbox, with dead-lettering) sits above both transports.http(default) is direct instance-to-instance HTTP, no extra containers, for the two-instance case;amqp(owner-directed,brokercompose profile running RabbitMQ) is for three-or-more instances and durability across long peer outages.amqplibis an owner-approved new dependency for the amqp transport. - Telegram adapter (v3.3.0): grammY is the owner-approved client library (MIT license, actively maintained) — long-polling only, webhook mode is a standing non-goal because it avoids inbound network config. Recommended setup is privacy-mode OFF (
@BotFather→/setprivacy→ Disable) plusrequireMention: trueinconfig/telegram-chats.json: Telegram's default privacy-ON mode never delivers plain-text messages — including plain@mentionsand this bot's!commandconvention — to the bot at all, so a privacy-ON chat is effectively reply-and-slash-command-only regardless ofrequireMention. Disabling privacy mode lets the bot see every message and then applyrequireMentionitself, the same shape as Discord's Message Content intent plusrequireMention; privacy-ON stays a valid, degraded fallback.TELEGRAM_CHAT_SCOPEdefaults toconfigured(notall, unlikeWHATSAPP_CHAT_SCOPE) — deliberately, because anyone can add this bot to any Telegram group via its@username, unlike a WhatsApp number which only joins groups the operator explicitly links. The model emits the same WhatsApp-style markdown taught everywhere else (*bold*,_italic_,~strike~); the Telegram adapter alone translates that into MarkdownV2 at send time (src/platforms/telegram/markdown.ts), so no persona or prompt authors need Telegram-specific syntax. Voice transcription reuses the existing Whisper/WHISPER_URLpath — no separate transcription config. Compose servicetelegramuses${TELEGRAM_HEALTH_PORT:-3005}for health. - Matrix adapter (v3.3.0):
matrix-bot-sdk0.8.0 (MIT) is the owner-approved client library, sign-off given with its disclosed ~2-year release gap (0.7.1 in 2024-01 to 0.8.0 in 2026-01) named plainly, not glossed over; a scopedform-data: 2.5.6override on itsrequest/request-promise-coredependency tree closes the criticals that legacy tree drags in. No E2EE:matrix-bot-sdknormally installs@matrix-org/matrix-sdk-crypto-nodejsas a regular dependency, and that package'spostinstallrejectslinux-arm64-musl; v3.3.0 overrides it to the repo-local no-native stub atstubs/matrix-sdk-crypto-nodejsand copiesstubs/before Dockernpm ci, so the Pi5 +node:25-alpineimage builds while encrypted-room support stays deferred. The stub exists only to satisfy unencrypted imports; attempted E2EE initialization throws, and the client logs a warning and sits blind if invited into an encrypted room rather than failing loudly.matrix-bot-sdkrequires Node >=22, above the project's Node 20 floor, so the version check is asserted at Matrix runtime construction (assertMatrixNodeVersion,src/platforms/matrix/client.ts) rather than raised package-wide. The client runs/synclong polling with the resume token persisted todata/matrix-sync.jsonviaSimpleFsStorageProvider, so a restart resumes instead of paying for a full initial sync; losing that file is harmless, just a slow resync. Room bindings inconfig/matrix-rooms.jsonare keyed by room ID, never alias — aliases can be repointed by any room admin, so the setup wizard resolves an alias to its room ID once at setup time and writes the ID as the config key.MATRIX_CHAT_SCOPEdefaults toconfigured, the same rationale asTELEGRAM_CHAT_SCOPE: anyone who knows the bot's Matrix user id can invite it to a room. The model emits the same WhatsApp-style markdown taught everywhere else; the Matrix adapter alone builds bothbodyand the HTMLformatted_bodyat send time. Homeserver rate limits (M_LIMIT_EXCEEDED) are operator-configurable, not a fixed vendor ceiling like Telegram's, so there's no proactive pacing — the client retries inline for waits <=2s and throws for anything longer, which the bridge outbox catches and defers rather than blocking. Replies only address the bot when the quoted author is the bot itself orm.mentionsnames it; a reply to anyone else must not wake the bot inrequireMentionrooms. Compose servicematrixuses${MATRIX_HEALTH_PORT:-3004}for health.
- TypeScript strict mode — no
anytypes, no implicit returns - ES Modules — use
import/export, notrequire() - Zod for all external input validation (env vars, API responses, message payloads)
- Pino for logging — structured JSON, never
console.log - Functional composition — prefer pure functions over classes; use classes only for stateful objects (socket, AI client)
- Error handling — always catch and log; never crash the process on a single message failure
- Naming:
camelCasefor variables/functions,PascalCasefor types/interfaces,SCREAMING_SNAKEfor constants - Files:
kebab-case.ts, one concern per file, max ~300 lines
import { WAMessage } from '@whiskeysockets/baileys';
import { logger } from '../middleware/logger.js';
export async function handleGroupMessage(
msg: WAMessage,
groupId: string,
): Promise<string | null> {
const text = msg.message?.conversation
?? msg.message?.extendedTextMessage?.text;
if (!text) return null;
// Only respond to @mentions
if (!isMentioned(text)) return null;
const query = stripMention(text);
logger.info({ groupId, query }, 'Processing mention');
// Route to AI
const response = await getAIResponse(query, groupId);
return response;
}- Use Vitest — files in
tests/named*.test.ts - Mock Baileys socket and AI clients — never make real API calls in tests
- Test message routing logic, formatting, config validation
- Run
npm testbefore every commit
# Run all tests
npm test
# Run specific test
npx vitest run tests/handlers.test.ts
# Watch mode
npm run test:watch- Commit messages:
type: short description(e.g.,feat: add weather command,fix: handle empty message body) - Types:
feat,fix,refactor,docs,test,chore - Branch from
mainfor features - Before every commit: Run
npm run check(audit:secrets → audit:deps → typecheck → lint → test). Fix any failures before committing. - Before every push / PR: Run
npm run gh:dependabotto check for open Dependabot PRs. The CI automation guard will block PRs if Dependabot PRs are pending. Either merge them first or add theallow-open-dependabotlabel with justification. - After every push: Monitor the GitHub Actions Quality Gate check. If it fails, fix the issue and push again — do not leave a PR with failing checks.
- Run
npm run checkbefore committing (runs secrets audit → audit:deps → typecheck → lint → test). Fix all failures before committing. - Run
npm run setup:hooksafter cloning to install the pre-commit PII/secret scanner - Run
npm run gh:dependabotbefore pushing or opening a PR — resolve or label open Dependabot PRs - Run
npm run typecheckafter editing TypeScript files - Run
npm run audit:secretsafter adding any config values, API keys, or identifiers - Monitor CI checks after every push — fix failures immediately, never leave a PR red
- Research existing tools/libraries/APIs before implementing any new feature or utility
- Validate all environment variables with Zod at startup
- Log errors with structured context (Pino)
- Handle Baileys reconnection gracefully (check
DisconnectReason) - Keep the bot process alive — never let a single message crash the service
- Use
.envfor all secrets — never hardcode API keys, tokens, or phone numbers - Save Baileys auth credentials on every
creds.updateevent
- Adding new npm dependencies
- Changing the AI model routing logic
- Modifying the Baileys connection config
- Adding new WhatsApp group bindings
- Any changes to runtime JSON configs under
config/(groups, Discord channels, bridge map, RAG sources) - Creating new feature files in
src/features/ - Modifying systemd service files or deployment scripts
- Hardcode API keys, tokens, or phone numbers in source code
- Commit personal email addresses, phone numbers, or private contact info — use project-level channels (GitHub Issues)
- Output, embed, or commit PII (names, emails, addresses) unless the owner explicitly requests it in the current conversation
- Auto-send messages without the bot being explicitly @mentioned (except moderation alerts to owner DM)
- Commit
.env,baileys_auth/, ordata/*.dbfiles - Delete or modify Baileys auth state files while the bot is running
- Run
sudoorsystemctlcommands without explicit user approval - Add autonomous agent behaviors (scheduled messages, proactive outreach) without user sign-off
- Use
console.log— use the Pino logger - Import from
dist/— always import fromsrc/ - Use CommonJS (
require) — this project uses ES Modules