The Guard for your Community.
A moderation bot for Vector communities. Screens text as it lands, judges media through a vision model you choose, contains raids, and keeps one legible score per member.
| Lane | Clock | Judges | Cost |
|---|---|---|---|
| Screen | every message, on arrival | words, links, regex, mention spam | local compute |
| Sweep | every poll_secs |
rate, repetition, cohorts, join bursts | local compute |
| Media | every attachment and linked image | a vision model you choose | one inference |
| Raid | a tripwire, then a full evaluation | many distinct accounts at once | local compute |
The split is deliberate. A word filter that answers on the next tick is not a word filter, and a rate limit has nothing to measure inside one message. Both paths run the same engine over the same policies, so a verdict reached live is the verdict the sweep would reach later, and both feed one ladder. A member escalates once, not twice.
vector-core engine ──convicts──▶ Sentire ──sentences──▶ warn · delete · kick · ban · contain
which rule matched, the ladder only what it has permission for
how grave, what it cited
The engine reports "this member matched this rule, here is the proof", and stops. Sentire decides what that is worth.
Sentire is always armed. What changes per community is what it has been granted.
| Granted | Behaviour |
|---|---|
| nothing | watches, judges, reports. Every verdict logged and DM'd to subscribers. |
MANAGE_MESSAGES |
also deletes what it convicts |
KICK / BAN |
also carries out the ladder, and contains raids |
Invite it, grant a role, and it begins. Revoke the role and it goes back to watching. One switch, in one place.
cp sentinel.example.toml sentinel.toml
SENTINEL_NSEC=nsec1… cargo run --release -- sentinel.tomlThe database lands beside the config (sentinel.toml → sentinel.db). A missing config is
the defaults: standard ladder, no custom rules, no media lane.
[bot]
nsec_env = "SENTINEL_NSEC"
communities = ["*"] # every community it is a member of
poll_secs = 90One number per member, small enough to say out loud.
| Offence | Adds |
|---|---|
| note / minor | 1 |
| serious | 2 |
| grave | 3 |
Strikes halve every week, so someone who stops falls back down. Actions sit at totals:
[ladder]
decay_half_life_hours = 168
steps = [
{ at = 1, response = "warn" },
{ at = 9, response = "kick" },
{ at = 12, response = "ban" },
]Default rungs give a grave offender two warnings, then a kick, then a ban.
Sentire climbs per offence, not per pass, and skips a rung it has no permission for rather than stalling beneath it. An unarmed rung is rehearsed and recorded, so the ladder keeps climbing past it.
[rules]
window_hours = 168
window_messages = 4000
raid_detection = true
[[rules.words]]
id = "slurs"
patterns = ["badword", "*wildcards*"]
gravity = "grave"
[[rules.links]]
id = "shorteners"
domains = ["bit.ly", "tinyurl.com"]
gravity = "serious"
[rules.rate]
enabled = true
per_secs = 60
messages = 10
gravity = "minor"
[rules.repetition]
enabled = true
times = 4
gravity = "minor"Standing spares the behavioural rules (rate, repetition, cohorts) for members holding a role. It never spares the word and link lists: what somebody posted is a content question.
Off unless configured, and it runs only in communities you name. Decrypted attachments reach a model, so that list is opted into per community rather than inherited.
Labels are yours. Each carries its own definition, threshold and gravity:
[vision]
enabled = true
communities = ["f54fbd83…", "fe4abeb3…"]
judge_links = true # a linked image is judged like an upload
concurrent = 1
max_bytes = 8388608 # what is sent to the model
max_sheeted_bytes = 104857600 # clips, cut to a contact sheet first
[[vision.labels]]
name = "sexual_content"
title = "NSFW"
describe = "Nudity or sexual content in ANY art style. Judge what is depicted."
threshold = 0.80
gravity = "grave"
[vision.video]
enabled = true # clips become a 3x2 sheet of frames
cols = 3
rows = 2
tile_width = 512Video is cut into a contact sheet and scored as the maximum over its frames, so a clip that is mild for five frames and explicit for one scores as that one frame.
Every classification prints all label scores, whether or not any cross:
[media] 401749301351 — gore 0%, sexual_content 70% — An anime-style illustration of …
Any OpenAI-compatible endpoint: llama.cpp, LM Studio, vLLM, or a hosted API.
[vision]
provider = "openai"
base_url = "http://127.0.0.1:8080/v1"
model = "your-vlm"
api_key_env = "" # empty = local, no auth
allow_remote = false # required for any host that is not loopbackThe request body is sealed with HPKE to a key an enclave proved it holds, so neither the billing proxy nor the operator of the machine running the model can read the attachment. Attestation runs at boot, and a failure refuses the send rather than falling back.
[vision]
provider = "tee"
model = "gemma4-31b" # enclave-internal id, no "private/" prefix
api_key_env = "PPQ_API_KEY"
enclave_host = "inference.tinfoil.sh"
enclave_repo = "tinfoilsh/confidential-model-router"
enclave_proxy = "https://api.ppq.ai/private"The verified release is named at boot:
media lane: gemma4-31b at https://api.ppq.ai/private
[attested: tinfoilsh/confidential-model-router v0.0.142 — 6dd91ce3a9b8]
The router is what gets attested. It terminates the encrypted channel, then forwards to a model enclave it verified itself, so what you have verified is that measured, publicly audited code handles the plaintext. Worth knowing before you describe it to a community.
Build without the verifier's dependency tree when only a local model is used:
cargo build --release --no-default-featuresTen commands, permission-gated. /help explains the bot itself and takes a topic.
| Command | Does | Needs |
|---|---|---|
/help [topic] |
what Sentire is, and how scoring works | anyone |
/status |
what it watches here, what it can do, how much history it sees | anyone |
/why <member> |
their strikes, the rung ahead, whether standing spares them | staff |
/pardon <member> |
strikes to 0, lifts a ban it placed | staff |
/kick <member> |
remove them; they can rejoin | kick |
/ban <member> |
remove them and keep them out | ban |
/notify |
reports by DM. Opting out never needs permission | kick or ban |
/words /links |
read and edit this community's lists | kick or ban |
/ladder |
read and edit the rungs | staff |
Every community tunes itself, in chat or in the file. Chat settings win and persist.
[community."<community-id>".ladder]
steps = [{ at = 1, response = "warn" }, { at = 60, response = "kick" }]
[community."<community-id>".raid]
response = "ban"Settable in chat: words · links · ladder · raid-response · raid-confidence ·
tripwire-accounts · tripwire-seconds · respect-trusted · window-hours ·
decay-half-life-hours
The tripwire decides when to ask, never who is guilty. It counts strangers rather than accounts, so a lively room of regulars costs nothing. The evaluation that follows names the cohort.
[raid]
response = "ban" # revokes the invite and rolls the root in one severing rotation
tripwire_accounts = 5
tripwire_secs = 30
protect_tenure_secs = 86400 # a day of tenure makes you establishedresponse = "ban" is the containment that scales: it revokes the door and rotates the root
in a single publish, rather than spending a banlist slot per arrival.
Fresh accounts are contained however many there are, so a wave of 500 is one publish. The halt exists for established members swept into a cohort, which is a misfire, and it defers to a person.
[Unit]
Description=Sentire moderation bot
After=network-online.target
[Service]
WorkingDirectory=/root/sentire
Environment=HOME=/root
EnvironmentFile=/root/sentire/.env
ExecStart=/root/sentire/sentinel /root/sentire/sentinel.toml
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.targetEnvironment=HOME is load-bearing. Without it the SDK falls back to a relative data
directory and starts from an empty database beside the binary, which looks healthy and
re-syncs from zero.
ffmpeg and ffprobe are required on PATH when vision.video is enabled.
cargo test307 tests, roughly as many lines of test as of code. src/harness.rs builds a community in
memory, runs the real policy engine over it, and converts the result through the same code
path the live bot uses, so "three offences reach a kick" is checked from the words to the rung
with no network involved.
Several tests parse the source itself, because the failures they catch are invisible from outside. A conviction keyed on the blob rather than the post still classifies, still flags and still logs, and silently stops charging for every repost after the first.
MIT, Copyright (c) 2026 JSKitty. See LICENSE.