Andrew Garman

A private Discord bot and an admin helper built in two days: Claude drafts each server change and waits for my go-ahead, nothing can be deleted, and a dwarven tavern keeper fights Discord's five-field form limit.

Illustration of a laughing white-bearded dwarf raising a wooden tankard in a lantern-lit tavern, with an elf playing a lute beside a roaring fireplace behind him

Innkeeper: Why Didn’t I Think of This Sooner?

Running a Discord server is a long list of small, fiddly clicks: a role here, a channel there, the right permissions on each one. Why didn’t I think of getting some help with that sooner?

Claude isn’t driving here. It’s more like a helper behind the bar. It works out what needs to change and writes up the exact plan, and I’m the one who says go. The project’s instructions tell it to show me every plan in plain words, including who gains or loses access, and wait for an explicit “apply”. The tools it works with can create channels, change permissions and post as the bot, but none of them can delete anything. That was in the very first commit, along with a rule that every change has to be rehearsed before it’s allowed to happen.

Innkeeper looks after the Fort, a private, invite-only server for my friends, with spaces for D&D, World of Warcraft, 3D printing and card games. I built it to learn about Discord bots, AI and home servers, and most of what’s here happened over two days, November 6th and 7th. It’s completely separate from Chronicler’s Companion, with its own repo, Discord application and token. It comes in two halves:

  • The bot handles role-picker buttons, anonymous feedback, and a couple of slash commands.
  • An admin MCP server gives Claude Code tools to audit the server and set up channels and roles.

Rehearse, then apply

Every write tool defaults to dry_run: true. A dry run changes nothing. It returns the exact list of changes it would make and a confirm_token. To apply the plan, the tool gets called again with the same arguments and the token, and the token has to survive this:

consume(tool: string, args: unknown, token: string | undefined, fingerprint: string): string | null {
  if (!token) return 'confirm_token is required when dry_run=false. Run a dry run first.';
  const p = this.pending.get(token);
  this.pending.delete(token); // single-use, even on failure
  if (!p) return 'Unknown or already-used confirm_token. Run a fresh dry run.';
  if (p.expires < this.now()) return 'confirm_token expired (10 minute limit). Run a fresh dry run.';
  if (p.tool !== tool) return `confirm_token was issued for ${p.tool}, not ${tool}.`;
  if (p.argsHash !== hashOf(args)) return 'Arguments differ from the dry run this token was issued for. Run a fresh dry run.';
  if (p.fingerprint !== fingerprint)
    return 'The server changed since the dry run, so the plan is no longer the same. Run a fresh dry run and review it again.';
  return null;
}

A token works once, for one tool, with those exact arguments, for ten minutes. The last check matters most: the plan gets fingerprinted, so if someone changes the server by hand between the preview and the apply, the apply is refused. What you approved is what happens, or nothing happens. The token can’t prove a person actually read the plan, though. That part is the instruction to wait for me, plus me reading it.

Tokens live in memory, so restarting the server cancels anything pending. Every applied change gets appended to a log with its before and after. Deleting stays a manual job in Discord.

The Fort’s topic channels and house rules were set up with these tools, and the rules went up as a post from the bot:

Discord screenshot of an Inn Keeper post titled House Rules of the Fort: 'The Fort is a tavern for friends. A few rules keep the fire warm.' 1. Be kind. Roast like friends, never punch down. No harassment, slurs or hate, including in jokes. 2. You vouch for who you invite. Bring good people! If someone you invite causes trouble, the Guild Council will talk to you both. 3. What happens in a private room stays there. Don't screenshot or share party, campaign or DM channels without asking the people in them. 4. Spoilers in tags. New raid, new set, last session's twist: wrap it in spoiler tags for a day or two. 5. Keep it in the right room. Each table has its channels; #general is for everything else. Not sure where something goes? #general. 6. Keep it PG-13 in shared rooms. Nothing graphic or NSFW outside spaces where everyone agreed to it. 7. Consent goes both ways. Don't record or transcribe voice chat without everyone's OK, and don't pressure anyone into a game, a party or a bot. 8. No spam or selling. Sharing your own projects is welcome; ads and self-promo bots are not. 9. Problems? Tell the Guild Council. DM any of them, or use the anonymous buttons in #start-here. Nobody gets in trouble for asking. The Guild Council has the final say, and we'd always rather talk than kick. Mind the rules, mind the mugs.
The house rules, posted through the same dry run and apply

Rule 7 applies to the bots as much as the people: don’t record or transcribe voice chat without everyone’s OK, and don’t pressure anyone into a game, a party or a bot. The same idea sits behind the feedback buttons below and the opt-in voice notes in Chronicler’s Companion.

A bot that can’t read the room

The bot connects with a single gateway intent, Guilds:

const client = new Client({ intents: [GatewayIntentBits.Guilds] });

No Message Content, no Server Members, no Presence. It can’t read chat, because it never asked to. These are my friends’ conversations and it has no business in them.

That goes further with the feedback buttons on the bot’s announcement posts. One of them says I’d rather the bots weren’t here. It’s one click, relayed anonymously, and the bot stores nothing. If you’re going to put AI in a friends’ server, there should be an easy way to say you don’t want it.

Thorin has no tools

Portrait of Thorin, a grinning dwarf with grey hair and a long braided beard bound with gold rings, holding up a wooden tankard in a lantern-lit tavern
Thorin's avatar on the Fort

/ask is an AI Q&A open to a few hand-picked people, with a default limit of 20 questions per person per day. It answers as Thorin “Keg-Slinger” Bronzebeard, a dwarven tavern keeper with a static set of notes about the Fort’s features. It’s a plain text completion and nothing else. Thorin has no tools, can’t see messages or members, and is never handed roster data. The usage database stores counts and never the questions or answers.

Here he is on his first day, introducing himself:

Discord screenshot. Drew used /ask: 'Can you introduce yourself?' Inn Keeper replies: 'Aye, welcome in! I'm Thorin Keg-Slinger Bronzebeard, the innkeeper of the Fort, part host, part trouble-shooter, and the one who keeps the Discord fires burning. In here, I look after the tables: Tabletop RPGs, Board games, The workshop (3D printing), The raid table (World of Warcraft). Ask away if you want help with rules, scheduling, printing advice, raid strats, or anything else that needs a steady hand and a good mug.'
Thorin's introduction, from his first day on the job

Later that afternoon I asked him to pass something along to the guild, and he told me he couldn’t:

Discord screenshot. Drew used /ask: 'Can you tell the guild how to register with the you to help track professions?' Inn Keeper replies: 'Aye lad, Thorin Keg-Slinger Bronzebeard here. I can't message the Guild Council directly, but you can tell 'em this: In #start-here, click World of Warcraft (the Raid Table) to get into the WoW side. Then use the command /wow register to add your character for profession tracking. Tell the Council you want it set up so the roster can show who can craft what. If you want, reply here with what you've got (Normal/PvP/RP mode + faction + class + professions), and I'll format it into a clean /wow register prompt you can paste.'
No tools means no messaging anyone, and he knows it

That’s the design doing its job: he can only answer, so he says so and tells me what to do instead. It’s also a snapshot of how fast things moved. The mode and faction he asks about were gone from the form by the next day.

Keeping it that way is a job for a build step, because “the AI part can’t touch the admin part” is the kind of rule that erodes one convenient import at a time. scripts/check-isolation.mjs fails pnpm check if src/ask/ imports anything beyond itself, the model SDKs and Node built-ins:

for (const [re, what] of [
  [/\btools\s*:/, 'a `tools:` field'],
  [/\btool_choice\b/, 'tool_choice'],
  [/\bfunctions\s*:/, 'a `functions:` field'],
  [/\bfunction_call\b/, 'function_call'],
  [/\bmcp_servers\b/, 'mcp_servers'],
  [
    /\bprocess\.env\b/,
    'process.env (config must be passed in, so the module never sees the Discord token)',
  ],
])
  if (re.test(text))
    errors.push(`${rel}: contains ${what}; /ask must be a plain completion with no tools`);

It’s a pile of regexes, not a sandbox, and someone determined could walk around it. It’s there to catch me (or an agent working for me) doing something careless at 11pm, and for that it’s enough. Pointing /ask at a local model is a config change, which is the direction I’d like to go.

Discord forms are small

/wow is an opt-in roster for WoW: Forever. People list their characters and professions, find crafters, and can wipe all of it with /wow forget. The first version took a day. Then it met reality.

One guild. I’d built support for several guilds, modes and factions. In practice the Fort has one guild, Alliance on Normal, so the form stopped asking. The database columns stayed, so a second guild wouldn’t need a migration.

Two names. WoW: Forever characters have a first and last name, which I only found out after the form was built.

Five fields. Discord forms allow at most 5 fields and 25 options per dropdown, and one dropdown can’t depend on another. First and last name ended up sharing one text box to make room for spec. The spec dropdown has its own bit of luck: Classic has 27 specs, which is over the limit, but Holy, Protection and Restoration each belong to two classes, leaving 24 unique names. It fits with one slot to spare. Since the form can’t narrow specs by class, the bot checks the combination after you submit:

else if (c.spec && !SPECS[c.class as keyof typeof SPECS].includes(c.spec))
  problems.push(`${c.class}s don't have a ${c.spec} spec. ${c.class} specs: ${SPECS[c.class as keyof typeof SPECS].join(', ')}.`);

Who gets in. Guild access requests started out going to a council channel. They moved to guild chat so anyone already in the guild can let someone in, and it’s still always a person clicking Approve. The bot can’t verify self-reported text, so it never decides.

The small stuff went wrong too. Typing a new character name into the register box returned “not found” until it counted as a new character. And a manual data fix blew up on a missing spec column, because a running bot only migrates its database at startup.

A web page for the things that want a screen

Some jobs are just easier on a screen than through Claude Code, so there’s now a local admin page for editing the roster, reading the change log and /ask usage, seeing who can see what, and posting as Thorin. It uses the same front-end approach as the Chronicler console: Lit with light DOM for anything stateful, CSS-only layout elements, design tokens, and two themes called Taproom and Hearth.

The most useful refactor was moving the dry-run engine out of the MCP tools into a shared runWrite. The page’s Preview changes and Apply this plan buttons now go through the same code as Claude, so there’s only one way to change Discord.

It’s only reachable from my own machine, and I locked it down anyway: it listens on 127.0.0.1, requires a password, checks Host to stop DNS rebinding, requires a same-origin Origin plus a custom header on every change, and sends a strict Content-Security-Policy. A local admin panel is a nice target for a malicious web page open in another tab.

Two things bit me on the way:

  • The strict CSP bans inline scripts, so the little “set the theme before first paint” snippet had to become its own file.
  • A Lit method I named remove clashed with the built-in HTMLElement.remove.

The Roster page also has a Move button, and it exists because of a real mistake: a character registered on someone’s behalf landed on my account, with no way to hand it over.

Running it from WSL

Discord traffic is outbound only, since the bot dials Discord’s gateway, so nothing has to be exposed to the internet. It runs as a systemd user service inside WSL. The catch is that WSL shuts its VM down when no Windows process is using it, which takes the bot with it. The fix is delightfully dumb: a Windows scheduled task at logon that runs sleep infinity inside WSL to hold it open.

wsl.exe -d Ubuntu --exec sleep infinity

The longer-term idea is to move it to a Pi or Mac mini.

What’s next

I looked into pulling gear and professions from Blizzard’s Battle.net API and haven’t built it. Character lookups need a realm in the URL, WoW: Forever has no realms, and I don’t yet know whether the API covers Forever at all. That needs a small probe script before anything else.

There’s also a collision coming. Chronicler’s Companion plans its own /ask, for campaign recall. Discord will let two apps register the same command, but one Thorin and one campaign archivist answering to the same name is asking for confusion, so if they end up on the same server one of them is getting renamed.