A Node.js Discord bot that manages your server end-to-end, with a mobile-friendly web dashboard and AI chat powered by OpenRouter. Designed to deploy on Railway from GitHub as a single service (bot + dashboard + voice in one process).
The bot lives in nodebot/. The previous Python implementation
that used to live at the repo root has been deleted.
Docs: overview · architecture · voice pipeline · operations · how Max thinks (concepts: pressure, memory, tools, wake pipeline, prompts, models, limitations, roadmap)
Bot (slash commands)
- Moderation:
/kick/ban/unban/timeout/untimeout/warn/warnings/clearwarnings/purge/slowmode/lock/unlock - Roles:
/giverole/takerole/createrole/deleterole - Channels:
/createchannel/deletechannel/settopic - Utility:
/ping/serverinfo/userinfo/say - AI:
/ask,/aireset,/manuscript,/knowledge, and the bot replies whenever it's @mentioned - AI tools: Tavily web search, GitHub repo analysis (share a repo link and the bot pulls its stats, languages, and README to discuss it), and full read-only visibility into the bot's own GitHub repo — every branch, contributor pull requests with full diffs, branch comparisons, commits, and file contents at any ref, for reviewing contributor work together in chat. Read-only, no create/update/delete/merge call anywhere in that path — merging is always a human decision.
- Repo sandbox (E2B): not available. This existed in the Python bot and was
deliberately not carried over to Node — it never worked well in practice.
E2B_API_KEYandGITHUB_WRITE_TOKENare unused. - Image and video generation: images via OpenRouter; video is a full narrated pipeline — script, per-scene illustrations, per-scene narration (Fish Audio/Edge TTS), FFmpeg assembly into one mp4 — that runs natively in this same process on whatever billed the OpenRouter key, not a separate app or machine. Gated per-guild (owner-only or everyone) with an hourly cap on video specifically, since it costs and takes noticeably more than an image.
- Music generation: composes an actual track with Google's Lyria 3 (via
OpenRouter) and posts it straight into the channel — a quick ~30-second
clip or a longer structured song. Before generating, the bot asks a few
clarifying questions (genre, mood, instruments, vocals/lyrics, clip vs
full song) rather than generating off the first mention of "song."
Access is a per-server role mapping (
music_roles,music_curator_roleson the dashboard, or granted from chat by an admin): with both lists empty it stays admin/owner-only, which is the default. Every generation is metered against the account's credits whether or not the result is kept. - Song libraries: each member keeps a personal library (10 songs); one
shared server library (30) is added to by curators. In voice, a member
who turns on sharing (
set_music_shareable) lets other people in the same channel play their saved songs — but only while they're actually in the channel, and nothing is ever copied between libraries.save_song,list_songs,delete_song,play_song,play_playlist,stop_music. - Document review: drop a file on a message that mentions the bot (or in an always-on AI channel) — text, markdown, code, PDFs, and Word docs are read automatically and folded into the conversation so the bot can summarize, answer questions about, or review what's in them.
- Proactive speech: a pressure engine (
pressure/, adapted from digital-pressure) lets the bot speak unprompted — messages and voice transcripts are classified into weighted signals (blockers, wrong claims, promised follow-ups, safety concerns…); pressure charges, decays, and flows, and a deterministic gate (thresholds, relevance, novelty, cooldowns, budgets, energy) rules on every drafted contribution —/pressureshows state or toggles it (owner) - Persistent memory, updated live: a working-memory file (current topic,
open questions, recent meaningful turns), a durable-memory file (dated
facts/preferences/decisions with confidence), and a per-member profile
card (goals, active projects, constraints, vibe notes, freeform notes)
are all rewritten after every single turn — text or voice, from anyone,
in every channel, tagged with exactly where it happened (
#general,voice/General, ...) — so something posted in one channel can be recalled later from a completely different channel or from voice, no batching delay; stored versioned in SQLite, injected into every reply;/memoryshows or wipes it (owner). Every raw turn is also persisted immediately (before consolidation runs) and kept forever as a permanent, searchable chat log — if a redeploy hits mid-consolidation, unconsolidated turns are replayed on restart instead of lost, and the bot can search the actual log (recall_chat_log) whenever a summary alone doesn't have it - Manuscript (owner-only, always on — no toggle, nothing to remember to
enable): every word the owner says, voice or text, is separately kept
verbatim — for long-form stuff meant to be kept word for word, like a
life story or a book draft, instead of boiled down into a fact or a
profile field. Completely separate from durable memory and profile
cards, never summarized, compressed, or rewritten.
/manuscriptsends it back as a text file, or clears it - Knowledge base: procedural memory, separate from durable/working/profile
memory (which is facts about people) — reusable "how to do X" steps.
Before improvising an unfamiliar multi-step task, or asking how to do
something, the bot checks it first (
kb_search); if nothing matches, it asks instead of guessing, then saves the resolved procedure (kb_save) so nobody has to walk it through the same thing twice. Guild-wide, not tied to one person;/knowledgelists or searches it, and the owner can delete an entry - Voice monitoring (in-process): the bot joins occupied voice channels itself,
speaking Discord's DAVE E2EE voice protocol via discord.js, receives each
speaker separately, transcribes them, flags banned words to the mod log, and
joins the conversation (text + TTS) when someone says a wake word —
/voicejoin/voiceleave(needsTRANSCRIPTION_API_KEY). Text and voice share one conversation buffer, so asking about something in voice that was said in text works, and vice versa. The oldlistener/sidecar and the HTTP bridge it needed are gone — no second process, noSIDECAR_PORT. - Presence by conversation, not just the dashboard: anyone can say (in text or
voice) "join us in voice" — he joins the asker's channel — or "leave the
call" / "Max, go to sleep" — he disconnects and stays out, no
auto-rejoin, until asked back or brought back from the dashboard.
Configurable as
voice_leave_words; the dashboard start/stop buttons drive the samevoice_sleepflag. - Follow-up mode: the wake word only has to be said once. For a few seconds
after Max finishes speaking (default 5), anyone in the channel can just keep
talking and he answers, and every real answer re-arms the window — so a
conversation carries on the way it would with a person. Two ways to end it:
"Max, stop speaking" cuts him off mid-sentence but stays in the
conversation, and "Max, stop listening" ends it and puts the wake word
back. If what he hears in the window plainly wasn't meant for him, he stays
quiet. All of it is per-server and editable from the dashboard
(
voice_followup_enabled,voice_followup_window_sec,voice_stop_speaking_words,voice_stop_listening_words); set the window to0to turn it off. - Phrases, not words: wake / cancel / stop lists are entered on the dashboard
as
[hey max] [max, you around?] [yo max]— one phrase per bracket pair, so a phrase can contain a comma. The old comma-separated form still parses, so nothing breaks mid-edit. Capitals and punctuation are ignored on both sides when matching, and what's stored is the tidied-up version, so what you see on the dashboard is exactly what gets compared. - Welcome/goodbye messages + autorole for new members
- Automod: banned words, invite-link blocking, mention-spam limits
- Cross-channel spam ban: a member posting the same message (or attachment
burst) into several channels within a short window — the signature of a
compromised account blasting the server — is auto-banned, with their
recent messages deleted server-wide as part of the ban. On by default;
thresholds configurable per server. Staff (Manage Messages) are exempt.
/unbanonce they've secured their account. - Mod log channel + persistent action history
Credits (only when run as a service — see below)
- A pooled credit balance per customer account, metered against every billable provider call: AI replies, background work, voice transcription, Fish Audio speech and music generation. When the balance hits zero the bot stops replying with AI, and moderation, automod, welcome and every slash command keep working
- Customer accounts, an order form, a staff queue, and credit issued by hand against an out-of-band payment — there is no card processing
Dashboard (mobile-first, works great from a phone)
- Overview: server + bot stats
- Members: search, warn/timeout/kick/ban, edit roles
- Server: create/delete channels & roles, send messages as the bot
- Mod: warning list, full moderation log
- Settings: welcome, automod, AI model/prompt/channels, log channel, bot presence
- Go to the Discord Developer Portal → New Application.
- Bot tab → copy the Token (this is
DISCORD_TOKEN). - On the same tab, enable Privileged Gateway Intents:
- ✅ Server Members Intent
- ✅ Message Content Intent
- OAuth2 → URL Generator: check
bot+applications.commandsscopes, and give it Administrator (or the specific permissions you want). Open the generated URL to invite the bot to your server.
Create a key at openrouter.ai/keys — this is OPENROUTER_API_KEY.
-
Push this repo to GitHub.
-
In Railway: New Project → Deploy from GitHub repo and pick it.
-
Add these variables (service → Variables):
Variable Value DISCORD_TOKENyour bot token CLIENT_IDyour Discord application ID — required to register slash commands OWNER_IDyour Discord user ID (management commands are owner-only) OPENROUTER_API_KEYyour OpenRouter key DASHBOARD_PASSWORDpassword for the dashboard SECRET_KEYany long random string DATABASE_PATH/data/nodebot.dbGITHUB_TOKEN(optional) GitHub token — raises the repo-analysis API rate limit TRANSCRIPTION_API_KEY(optional) OpenAI or Groq key — enables voice monitoring FISH_API_KEY(optional) fish.audio key — natural TTS voice for spoken replies FISH_VOICE_ID(optional) fish.audio voice model reference id to speak with FISH_TTS_MODEL(optional) fish.audio model, default s2.1-pro-free(free tier)PLATFORM_STAFF_EMAILS(optional) comma-separated emails allowed to see the order queue and issue credits. Leave empty unless you are running this as a service -
Attach a Volume to the service mounted at
/data(so settings/warnings survive redeploys). -
Settings → Networking → Generate Domain to get your dashboard URL.
Open the domain on your phone, log in with DASHBOARD_PASSWORD, and manage everything
from there.
Needs Node 22.5+ and ffmpeg on PATH for TTS playback.
The bot stores everything in node:sqlite, which is built into Node but not
switched on across all of Node 22: it landed in 22.5.0 behind
--experimental-sqlite and was only unflagged in 22.13.0. The npm scripts and
the deploy start command pass that flag unconditionally — it is required at or
below 22.12 and a harmless no-op above it. If you run a script directly rather
than through npm, pass it yourself or you'll get
ERR_UNKNOWN_BUILTIN_MODULE: No such built-in module: node:sqlite.
cd nodebot
npm install
cp .env.example .env # fill it in
npm run deploy-commands # register slash commands (needs CLIENT_ID)
npm startRun the tests with npm test from nodebot/.
Dashboard: http://localhost:8000
One instance runs one server, and dashboard access follows that server's own Discord roles — there's no second list of people to keep in step.
| Level | Who | What they get |
|---|---|---|
| creator | OWNER_ID, plus the DASHBOARD_PASSWORD login |
Everything, including the bot's global log, presence, and restart |
| admin | Discord Administrator, or a role in dashboard_admin_roles |
Everything for the server: persona, models, voice, automod, welcome, channels and roles |
| moderator | A role in dashboard_mod_roles |
Members, warnings, mod log, transcripts, quiet mode. Read-only on settings |
People sign in with Sign in with Discord; the bot then looks them up in its own server over the gateway and reads their roles from there — never from anything the browser sent. Map the roles under Settings → Dashboard access.
Leave both role lists empty and it falls back to Discord's own permissions, so
a fresh install works before it's configured: Manage Server counts as admin,
and kick/ban/timeout counts as moderator. Once you map roles, those become the
source of truth. OWNER_ID is always creator and cannot be locked out, and the
password login stays as break-glass if OAuth is misconfigured.
To enable Discord login, add to the Discord Developer Portal → your app →
OAuth2 → Redirects: https://<your-dashboard>/api/auth/callback, then set
DISCORD_CLIENT_SECRET (and PUBLIC_URL if a proxy rewrites Host). Without
the secret, the dashboard stays password-only.
Levels are enforced per route on the server, not just hidden in the UI, and a route that doesn't declare a level is treated as creator-only — so a new one fails closed rather than open.
Two models are configured per server, and they fail differently:
- Conversational (
ai_model) — somebody just spoke to her and is waiting. On a 429 she says which backend is down and offers three alternatives with what each one costs, then switches when told to. By voice: say "B", "the second one", "switch to Haiku", "switch back", or "never mind". - Background (
ai_utility_model) — memory, classification, de-escalation. Reroutes itself and logs the switch. Nobody is around to answer at 3am, and the alternative is that memory consolidation quietly stops for the day.
The list of what she can switch to comes from OpenRouter's model catalog, refreshed hourly and cached in SQLite so a restart or an OpenRouter outage still leaves her alternatives at the moment she needs them. A model that returns 429 is parked rather than dropped — fifteen minutes for a burst limit, six hours for a daily free-model quota, because that one is not lifting soon.
Ask her any time with "what models can you use?" (list_ai_backends) or
"switch to Sonnet" (switch_ai_backend, owner only).
The answer-matching is deliberately plain string work — no model call. The whole feature fires when the backend is unavailable, so anything that needed a model to interpret "switch to B" would be broken exactly when it is needed. That is also why the options are lettered: single letters survive a bad transcription far better than model names do.
Each option shows what it bills at under the rate card, because switching from a Haiku-class to an Opus-class model changes a managed customer's per-reply cost from 2 credits to 8 — not something to discover on an invoice.
If you self-host, this section does not apply and nothing here is switched on. A Discord server is metered only if it has been deliberately registered as a customer's bot; a server that has not been is never metered and never gated, with no flag to set. That is the default.
Run as a service, the shape is:
- A customer signs up at
<dashboard>/site/app.htmland fills in the order form at/site/build.html— venue, their Discord server, and what they want the bot to do. No account is needed to submit one. - It lands in the staff queue at
/site/admin.html. Someone gets in a room with them and builds the bot out together — that is the product, not a stage waiting to be automated. - They pay however you agreed. There is no checkout. A member of staff issues the credits against a payment reference, which is required.
- The bot draws on the account's pooled balance. Every reply, background call, transcribed minute and spoken minute is metered after it happens, from real token and duration counts.
- At zero the bot says so once an hour and stops replying with AI. Everything that costs nothing to run keeps running — which is what stops a lapsed account turning into an unmoderated server.
To make an account staff, sign it up and put its email in
PLATFORM_STAFF_EMAILS. That is the bootstrap (the first staff account cannot
be promoted by an existing one) and the way back in if the database is what you
cannot reach.
Enterprise customers run on their own provider keys: their usage is recorded at list price so they can compare it against their own invoices, and never billed or gated.
Full detail — data model, pipeline, metering rules, API, and what is
deliberately not built — is in site/PLATFORM-SPEC.md.
One credit is one cent of list price. Internally the ledger counts thousandths of a credit, because background work costs 0.2 credits a call and is the large majority of call volume — on an integer-credit balance it would round to free.
The system prompt is assembled from two separately editable settings, both on the dashboard's Overview tab:
| Setting | What it is |
|---|---|
ai_system_prompt |
Character — who he is, how he talks. Yours to write. |
ai_capability_prompt |
Capability — what he can actually do. |
Both default to the text in src/persona.js. A server that has never saved its
own copy keeps getting the current default, so neither can be lost to a fresh
database, and the capability half stays true as features land. Save either from
the dashboard and that server is pinned to its own copy from then on — nothing
overwrites it afterwards.
The slash-command list is not part of either one. It's generated from the
live command table on every request (src/systemPrompt.js), so it can't go
stale, and a stored persona can never claim a command that no longer exists.
Full assembly order: character → who he is and which server he runs, with the command list → capability → owner or member note → what he remembers. Text chat and voice call the same builder, so the two surfaces can't describe different bots.
Keep the capability half honest. A persona that claims a tool the bot doesn't have produces a bot that confidently lies about what it did.
npm testenforces this: every tool and slash command named inCAPABILITY_PROMPTmust actually exist.
Settings (persona, welcome messages, banned words, log channel, autorole, model override, wake words) carry across; chat history and memories do not:
node --experimental-sqlite nodebot/src/migrate-settings.js --from /data/bot.db --to /data/nodebot.dbAdd --dry-run to see what would move without writing anything.
Point DATABASE_PATH at a new file — not the Python bot's bot.db. Both
schemas use the same table names, so CREATE TABLE IF NOT EXISTS is a no-op
against the old database and the bot would come up looking healthy while
mangling every Discord id: Python stores snowflakes as INTEGER, and anything
past 2^53 comes back to JS as a rounded float (1234567890123456789 →
...800), so warnings, mod logs and per-member memory would key to the wrong
user. The bot detects this at startup and refuses to start rather than corrupt
data, printing the migration command above.
Note: locally the session cookie is marked
secure, which most browsers still accept onlocalhost. Slash commands are synced per-guild on startup, so they appear immediately in servers the bot is already in.
- All state lives in one SQLite file (
DATABASE_PATH). Without a Railway volume it resets on each deploy. - AI model, system prompt, and always-on AI channels are per-server settings in the dashboard. Any OpenRouter model ID works.
- The dashboard is a single password for full control — use a strong one, and keep the Railway domain private.
- Management commands (moderation, roles, channels, welcome,
/say) only work for the user whose ID is inOWNER_ID. AI chat (/ask, @mentions) and info commands (/ping,/serverinfo,/userinfo) are open to everyone.