Self-hosted auto-moderation for Armada: meet Master-at-Arms

    Self-hosted auto-moderation for Armada: meet Master-at-Arms

    Armada Communities are serverless and end-to-end encrypted, so the relay can't moderate for you. Here is the bot we built to watch every message and act on spam in seconds, and how to run it yourself.

    Derek Ross
    🛡️ Moderation
    🚢 Armada
    ⚡ Nostr
    🔐 E2EE
    🔧 Self-hosting

    1. Moderation is the part nobody builds for you

    On a centralized platform, moderation runs on someone else's servers. Keyword filters, rate limits, the ban button, all of it executes in code the platform owns.

    Armada Communities are serverless by default and Armada Communities are end to end encrypted. This makes moderation unique. Because of this, a relay could want to block spam and still be blind to it. The only actors who see a message in plaintext are the members of that community and the clients holding their keys.

    So auto-moderation on Armada has to be a bot since we cannot rely on relays here, and specifically a bot that is itself a member of the community. It is invited in, granted a moderator role, and handed exactly the authority a human moderator would hold. It reads the same decrypted stream a human moderator reads. The difference is it never sleeps, and it reads every single message.

    This is Master-at-Arms, the auto-moderation bot for Armada. It is a standalone, single-purpose bot built on the Vector SDK for the Concord protocol that Armada Communities run on, and any community operator can run it themselves. Discord-style AutoMod, adapted for a protocol where there is no central anything. It is MIT licensed, and it runs on the same end-to-end encrypted rails your community does.

    2. Why a bot is the only honest answer

    The hard part about moderation on Armada is not the mechanics of kicking someone. It is that everything about the system is designed so no third party can see anything. That is the point of the protocol, and it is exactly why moderation cannot be handed to infrastructure.

    Consider what the alternatives would look like. The relay cannot do it: it stores gift wraps, not words. A moderation service cannot do it: you would be handing your community's plaintext to another server, which defeats the entire design. A human mod team cannot do it in real time: a spam flood is over in seconds, and the community is too small to staff around the clock anyway.

    The bot closes that gap without breaking the model. It is a peer. It joins like a member, holds the community key like a member, and is bound by the same role system as a member. When it bans someone, it does it through the protocol's own authority model, the same call a human moderator's client would make. There is no new trust to place in a third party, because the bot is not a third party. It is a member with a very specific job and no capacity to get tired. But trust is still very much in play, and it is worth being precise about where: the host that runs the bot holds a key that can read every message in the community. That is the tradeoff behind self-hosting a moderation bot, and it is spelled out under known limitations below.

    3. How it works

    Every community message is scored against seven detection rules before the command dispatcher ever sees it. Each rule that fires adds points, and the total decides the action.

    RuleFires whenPoints
    Rate burstmore than 8 messages in 10 seconds+3
    Duplicate content3 identical messages in 60 seconds+4
    Banned keywords / regexa banned word or pattern appears+5 per hit
    Link filteringa non-allowlisted link appears+2 each (+4 for new members)
    Mention spammore than 5 npubs in one message+3
    New-account floodingnew member exceeds 3 messages in the grace window+4
    Caps / wall of textshouting or a 1000+ char wall+1

    Every number in that table is a default, not a rule set in stone. All of it is configurable in the [automod] section of bot.toml, which the configuration section below walks through, so you can tighten or loosen any rule to fit how your community actually talks. The thresholds themselves are simple: a score of 3 or more deletes the message and warns the sender, 5 or more kicks, 7 or more bans. The defaults are tuned conservative, which is why the whole engine is off until you turn it on, and it is worth revisiting them after you have watched dry-run against real traffic.

    Two mechanisms stop it from being trigger-happy. The first is escalation. Repeat offenders get bumped up the ladder regardless of their raw score: the second actionable violation inside a rolling 24-hour window forces at least a kick, and the third forces at least a ban. Crucially, violation history survives a kick, since a kick emits a member-leave event and wiping it there would let a rejoining spammer reset their counter every time. !automod reset <npub> is the explicit wipe, and only staff can do it.

    The second is the action cooldown. Messages are handled concurrently, so a 20-message flood would otherwise fire 20 kicks and 20 announcements. Inside a 60-second window, an equal-or-weaker repeat action is suppressed: the offending messages are still deleted, but the offender gets one enforcement action and one announcement, not twenty.

    Then there is the immunity model, which I think is the most important design decision in the whole bot. Master-at-Arms takes orders from the community's own trust hierarchy. The owner and any admin or moderator, meaning any role with management permissions, are immune to it and control it. The bot operator and an explicit authorized list are immune too, unless strict_mode is on. The owner is always immune, and the bot defensively refuses to moderate the owner even if it somehow reaches that path.

    When you first switch auto-mod on, it does not start enforcing. It runs in dry-run for 60 minutes, announcing exactly what it would have done, score and rules included, and taking no action. That is your window to sanity-check the thresholds against real traffic before the bot is allowed to kick anyone. The window is measured from the moment it was enabled, not from process start, so a restart does not quietly put a live engine back into log-only mode. !automod dryrun off goes live early if you are confident.

    Every enforcement action lands in an append-only JSONL audit log under data/, bans DM the operator, and in-channel announcements are on by default. There is also a deafness watchdog: if the bot stops hearing messages for 30 minutes, it force-refreshes its subscriptions with exponential backoff.

    4. How to self-host it

    The source lives on Nostr rather than on GitHub, so grab it with ngit installed and the nostr:// remote helper on your PATH:

    git clone nostr://npub18ams6ewn5aj2n3wt2qawzglx9mr4nzksxhvrdc4gzrecw7n5tvjqctp424/relay.ngit.dev/concord-automod

    From there the quickstart is six steps, and the longest one is waiting for the Rust build.

    1. Generate a fresh identity. nak key generate (or any Nostr keygen). Never reuse a personal key or another bot's.
    2. Configure it. cp config/bot.toml.example config/bot.toml, then set bot.nsec to the fresh key and auth.owner to your npub. The owner is the operator: invites from them auto-accept, and only they can drive the bot over DM. Day-to-day control in each community comes from that community's roles, with nothing to configure bot-side.
    3. Build and run. cargo build --release, then ./target/release/concord-automod. Note the bot's npub from the startup log.
    4. Invite it. From your Armada app, invite the bot into the community using the account whose npub you set as auth.owner. With the default invite_policy = "owner", the invite auto-accepts.
    5. Grant it a role. It needs a moderator role with KICK + BAN capability, and it must outrank the members it moderates.
    6. Turn it on. !automod on. It starts in dry-run for 60 minutes.

    Everything you change at runtime persists across restarts as JSON under data/. The runtime config file wins over bot.toml after the first run, so !automod on, banned words, and allowlist changes survive a reboot. Delete data/automod-config.json to fall back to the TOML values.

    There are three ways to run it long-term. A systemd user unit ships in deploy/concord-automod.service, with loginctl enable-linger so it survives logout. A two-stage Dockerfile in deploy/Dockerfile builds a slim image with config/ and data/ mounted and the nsec supplied via env var. And deploy/install.sh does the one-command system install, creating a dedicated service user under /opt with a systemd unit. Running multiple bots on one host is supported, but each needs its own working directory and its own nsec.

    5. How to configure it

    The [automod] section in bot.toml is every knob in one place:

    [automod]
    enabled = false              # master switch; !automod on flips it at runtime
    strict_mode = false          # true = authorized users are checked too
    
    max_messages = 8             # rate burst
    burst_window_secs = 10
    max_duplicates = 3           # duplicate content
    dedupe_window_secs = 60
    
    banned_words = ["free crypto giveaway", "double your sats"]
    banned_patterns = ['(?i)t\.me/\w+', '(?i)discord\.gg/\w+']
    
    link_action = "flag"         # "off" | "flag" | "block"
    max_links = 3
    link_allowlist = ["github.com", "gitlab.com", "nostr.org", "npmjs.com", "crates.io", "docs.rs"]
    
    max_mentions = 5             # mention spam
    new_user_grace_secs = 120    # new-account flooding
    new_user_max_msgs = 3
    caps_threshold_pct = 70      # caps / wall of text
    caps_min_length = 20
    max_msg_length = 1000
    
    warn_threshold = 3           # delete + warn
    kick_threshold = 5           # kick
    ban_threshold = 7            # ban
    
    escalation_kick_after = 2    # Nth violation in 24h forces at least a kick
    escalation_ban_after = 3     # ...or at least a ban
    action_cooldown_secs = 60    # one enforcement per burst
    
    announce_actions = true      # post "kicked X for spam" in-channel
    announce_dm_owner = true     # DM the owner on bans + permission gaps
    delete_spam_messages = true

    Two details worth knowing. Single banned words match on word boundaries, so banning scam does not also ban scamper or descambiar; multi-word phrases match as plain substrings. And link_action = "block" guarantees at least a warn even for a single non-allowlisted link, where plain "flag" would just add points.

    The [watchdog] section is worth tuning before you deploy. silence_secs defaults to 30 minutes and must sit comfortably above the longest normal gap between messages in your busiest channel, because a quiet community is indistinguishable from a broken subscription. Set it too low and the bot will resubscribe continuously against a perfectly healthy community.

    6. How to use it

    The command surface is deliberately small, because this bot does one job. !help prints it; !auth tells you your standing.

    commandwhowhat
    !pinganyoneliveness check
    !helpanyonecommand help
    !authanyoneyour standing: operator / community owner / staff / none
    !automod statusstaffconfig + stats
    !automod on / offstaffmaster switch
    !automod dryrun <on|off|auto|status>stafflog-only mode control
    !automod words <add|remove|list>staffbanned words
    !automod allowlist <add|remove|list>stafflink-filter allowlist
    !automod history [npub]staffrecent enforcement actions
    !automod reset <npub>staffclear a user's violation history

    The subtle part is who counts as staff. It is not a bot-side roster. Master-at-Arms reads the community's own roles: the community owner, any admin, and any moderator, meaning any role with management permissions, controls the bot in that community. A typical Moderator role with KICK + BAN qualifies. The bot operator set in auth.owner passes every gate everywhere, which is what makes driving the bot over DM possible at all. When a new mod is promoted in the community, they get control of the bot automatically, and when they are demoted, they lose it. There is no separate list to keep in sync.

    7. Demonstrations

    The crypto giveaway. Someone drops "Click here for a FREE CRYPTO GIVEAWAY!!!" in general. free crypto giveaway is on the banned list, and one keyword hit scores +5, which is already at the kick threshold. The message is deleted and the channel sees:

    👢 Kicked nostr:npub1... for spam (banned_keyword).

    The duplicate flood. A user pastes the same "buy now" line three times in under a minute. The duplicate rule fires at +4, past the warn threshold, and the first message is removed:

    🚫 nostr:npub1... message removed for spam (duplicate_content). Please knock it off.

    The other copies in the burst are still deleted, but the action cooldown means nobody gets spammed with ten announcements.

    The repeat offender. First violation scores a warn. The second inside 24 hours is bumped to at least a kick no matter what it scores, and the third is bumped to a ban. If the offender is kicked and rejoins, the ladder does not reset, because the violation history survived the kick. That is the escalation rule doing its job, and !automod history npub1... shows the whole ladder.

    New-account flooding. A fresh npub joins and starts dropping links. Inside the 120-second grace window their link score doubles from +2 to +4, and each message past the limit adds +4 on top. Two non-allowlisted links from a brand-new account is 8 points on the spot, which is an instant ban.

    Dry-run. The day you set it up, !automod on replies in-channel that auto-mod is on, then adds that it is running log-only for 60 minutes. Each would-be action shows up as:

    🟡 DRY-RUN: would kick npub1... (score: 5, rules: banned_keyword), no action taken

    That is the whole point of the window: you see what the thresholds flag against real traffic before any of it becomes enforcement.

    8. Current known limitations

    A self-hosted bot means handing your community key to one more machine.

    Master-at-Arms is a member, and members decrypt the community stream, so the server running the bot can read every message. The bot itself is built to keep that content off disk: duplicate detection stores hashes rather than text, the audit log records action, score, rules, and npub with message snippets strictly opt-in, audit entries are pruned after 30 days by default, service logs carry metadata only (never message content), and every state file is written owner-readable. The remaining exposure is the Vector SDK's own protocol database, which keeps decrypted history at rest; the honest mitigation there is full-disk encryption on the host. Run it on hardware you control and trust, and treat the nsec like the key it is.

    • Every ban invalidates outstanding invite links. A ban triggers a read-cut key rotation, so invite links minted before it hand out stale keys. Anyone who joins through a stale link lands on a severed plane: their client shows them a community that looks alive, but current members and the bot correctly cannot see anything they post. In an actively moderated community, links can go stale within minutes of being minted. Re-mint invite links.
    • Automod settings are global per deployment, not per-community. One bot running for several communities applies the same banned words, thresholds, and dry-run state to all of them; !automod on and off scope per community, but the tuning underneath is shared. This bit us the first day, when a test community and a production community wanted different postures from the same bot. Per-community config is the top roadmap item.

    Run your own Master-at-Arms

    Everything in this post is MIT licensed and running in production today, including in our own community. It is one Rust binary with no database, no AI, and no services behind it, and it idles in under 20 MB of RAM, so a five dollar VPS or a Raspberry Pi hosts it with room to spare. Clone it, give it a fresh key, invite it aboard.

    Related Reading

    Soapbox is funded by grants and donations, not ads or data sales.

    Everything we build is open source and belongs to the community. Help us keep it that way.

    Learn how we use funds →