SYSADMIN_ARCHIVES
[12] WIPE: servers wipe every ~90 days
[31] TRADER: no building within 1000m
[47] ERROR: 0x00040010 = ADMIN_KICK
score = Σ keyword_hits
if score < 1.0: return []
# return [] == stay silent
# stay silent == spend $0
FACTS: [3, 7]
FACTS: [4, IMG]
NO_ANSWER
SELECT * FROM facts
WHERE manual = 1
ORDER BY id
crawl → hash → diff
└── changed pages only
suppressed_answers.jsonl
├── question
├── answer
└── reason

CDN_Captain: Teaching a Discord Bot to Shut Up

Jul 28, 2026
11 min read
PythonDiscordRetrievalRAGPlaywrightSQLiteDocker

Every community Discord has the same nine questions. When does the server wipe? Why did I get kicked with error 0x00040010? How far from a trader can I build? Can I get unbanned? They arrive at 3 a.m., they arrive in triplicate, and they arrive with a confidence that suggests nobody has ever scrolled up.

The obvious fix is to bolt a language model onto the Discord API and let it answer. I did not do that, because the obvious fix produces something worse than an unanswered question: a bot that is fluent, instant, and wrong. A confidently wrong bot in a gaming community doesn't just fail — it generates support tickets, because now people are quoting it at the admins.

So CDN_Captain was built around an unusual design goal. Its most important feature is not answering. It's silence.

The $0 Gate

Before any message reaches a model, it hits a scoring function written in plain Python. Keywords are extracted, stop words dropped, a small hand-tuned synonym map expands them — wipe also means reset, restart, map reset; trader also means market, vendor, safe zone — and every fact in the database is scored against the result.

If nothing clears a threshold of 1.0, the function returns an empty list, and an empty list means the bot says nothing at all. No API call. No token spent. No apology paragraph explaining what it doesn't know.

This is the single most valuable line of code in the project, and it contains no intelligence whatsoever.

Retrieval-gating this way has a pleasant second-order effect: cost scales with relevant traffic, not total traffic. A raid at 2 a.m. that fills a channel with 400 messages of trash talk costs exactly nothing, because none of it scores.

Where the Facts Come From

Once a week, Playwright crawls cdndayz.com — capped at 60 pages, four at a time, because politeness is free. Each page's content is hashed and compared against the last crawl.

The diff is the point

Only pages whose hash actually changed get sent for fact extraction. A site that didn't change costs one crawl and zero extraction calls. The rules page has not been edited since March. I am not paying to re-read it every Sunday.

Extraction is deliberately merciless about granularity. The prompt refuses summaries and demands that every cause, fix, step and number become its own tagged line:

RULE: No building within 1000 metres of any trader
WIPE: All CDN servers wipe approximately every 90 days
ERROR: 0x00040010 = ADMIN_KICK — player removed by an administrator
ERROR: 0x00040010 fix = if a restart was announced, wait and reconnect
REP: Black Market unlocks at 50,000 rep

Fine-grained facts retrieve better than paragraphs, and — more importantly — they can be cited individually. That matters in a minute.

The override hatch: a hand-written knowledge.txt sits above everything the crawler produces and always wins. 61 lines of facts that I control absolutely. When the model misreads a page, I don't debug the prompt at midnight — I write one line and move on.

Cite Your Sources or Say Nothing

When facts do match, the bot makes exactly one answer call. The retrieved facts go in numbered, and the system prompt — frozen, byte-identical on every call, with nothing interpolated into it — ends with a hard requirement:

End every answer with the fact numbers you used:
FACTS: [3, 7]
If you cannot honestly cite at least one,
NO_ANSWER

Here's the part that makes it work: the citations are not taken on faith. A deterministic function parses that line and checks every cited ID against the set of facts that were actually retrieved. Cite a fact that wasn't in the context? Rejected. Cite a screenshot when no image was attached? Rejected. Cite nothing at all? Rejected.

Asking a model to cite its sources is a prompt. Verifying those citations in code is an invariant. Only one of those survives contact with a model having an imaginative afternoon.

Then it gets checked again

A second, independent call acts as a grounding verifier: here are the sources, here is the proposed answer, is every concrete claim actually supported? Numbers, distances, rules, item names, causes, fixes. Tone and greetings are explicitly ignored. One line back: GROUNDED, or UNGROUNDED with the offending claim named.

One deliberate compromise: the verifier fails open. If that API call errors, the answer ships. It felt wrong to write, and I'd defend it anyway — a provider outage should not be able to silence a correct, properly-cited answer. The citation check, which is pure local code and can't have an outage, still fails closed. The strict gate is the one that never goes down.

The Airdrop Incident

All of that scaffolding exists because of a specific, humbling afternoon.

Someone asked whether vehicles drop from airdrops. The bot said yes. It further volunteered that you could claim one within a 500-metre radius. Both details were completely invented, delivered in the calm register of a man reading from a manual, and — this is the genuinely dangerous part — they were plausible. They sound like DayZ. Players went looking for cars that do not exist.

That incident is now permanently embedded in the test suite as a golden question, and it is the reason the suite exists at all:

question: "do vehicles drop from the airdrops?"
must_contain_any: [gear, medical, no vehicles]
must_not_contain: [contain vehicles, get a vehicle, 500m]

Fifteen of these now run offline and free against recorded fixtures, plus a live mode that costs real tokens for when I've changed something load-bearing. Every hallucination the bot has ever produced in public has been converted into a test that fails if it ever tries again. It's regression testing, except the regressions are lies.

Nine Ways to Not Answer

A technically correct bot that interjects constantly is still an unbearable bot. Retrieval decides whether it can answer; a separate stack of checks decides whether it should:

Is it even a question?

Statements are not invitations. Neither is someone venting.

Two-person conversation

If two players are clearly mid-exchange, the bot stays out of it. Nobody likes the guy who joins a conversation to correct it.

90-second staleness

After a restart it will not answer a question from last Tuesday. Necromancy is not a support strategy.

30-second cooldown + dedup

Per user, plus a five-minute memory of what it just answered in that channel. Rephrasing does not summon it twice.

The prompt carries a few more refusals earned the hard way. Never substitute one map's rules for another — DayZ maps are genuinely different and a confident cross-map answer is worse than nothing. Error codes must match exactly, never "close enough". And it will not reveal the Black Market's location on any map, even if a retrieved fact appears to describe it, because some knowledge is supposed to cost you something to find.

There's also a small rule I'm disproportionately fond of: never tell anyone to join the Discord. They are, definitionally, already in the Discord. Support bots love this advice. It is never once useful.

Every Silence Is a To-Do

When an answer gets suppressed — bad citations, ungrounded claim, whatever — it isn't just dropped. It's appended to suppressed_answers.jsonl with the question, the rejected answer, and the reason.

That file is the single most useful artifact the system produces. It is a ranked, self-maintaining list of things my community wants to know and my knowledge base cannot answer. The bot's failures write my documentation backlog for me.

On top of that sits a human feedback loop, because automated verification only gets you so far. Anyone can react 👍 or 👎. Three downvotes on one answer and I get a DM. Admins get sharper tools: ✅ confirms an answer as good, ❌ marks it wrong and deletes it from the channel — because a wrong answer sitting in the scrollback keeps being wrong long after everyone stops reading.

Where It Lives

It runs in Docker on my TrueNAS box, which is doing enough already and did not complain. All state — the fact database, conversation memory, both log files — sits on one mounted volume. First boot against an empty volume triggers a full site ingest, which takes about a minute and well under a dollar of credit, then it settles into the weekly rhythm.

A few things I'd call load-bearing

One config module — no other file reads an environment variable, and a validator refuses to boot on a missing token rather than dying mysteriously an hour later.
Results returned by value — no stashing state on function attributes between calls. Async plus hidden mutable state is how you get a bot that answers the wrong person.
A staleness alarm — if the knowledge base hasn't refreshed in fourteen days, I hear about it. A silently stale bot is just a confident bot with old opinions.

What I'd Tell Past Me

Roughly 1,700 lines of Python, and the interesting engineering is almost entirely about restraint. The model is the least remarkable component — it's a small, cheap one, called once, with its output treated as a proposal that has to clear two checks before anyone sees it.

Everything that makes CDN_Captain trustworthy is boring, deterministic code wrapped around that call: a scoring function that gates it, a parser that verifies its citations, a test suite built from its past lies, and a log of every time it chose silence.

Which turned out to be the actual lesson. Building something useful with a language model had very little to do with prompting and almost everything to do with deciding, precisely and in advance, what it is not allowed to say.

The smartest thing my bot does is nothing at all.