-
-
Shield tab: coordinated burst auto-detected — u/spambot_test posted 4 times in 10 min. One-tap Nuke campaign removes all as spam.
-
Shield tab: Nuke campaign removes all 4 burst items as spam in one tap — cluster cleared instantly
-
Triage tab: HIGH-risk item flagged with 3 signal chips and plain-English explanation — inline Approve/Remove in one tap.
-
Triage tab: Bulk-approve all Noise items in one tap — routine backlog cleared instantly
-
Triage tab: Queue cleared, smart empty state shows items handled, high-risk caught, and estimated time saved
-
Settings: three sensitivity presets — one tap re-scores all active items instantly
-
Insights: real-time stats — handled items, high-risk surfaced, time saved, top signals by frequency
-
Insights audit log: every mod action logged with timestamp, bucket, and signal chips — full accountability trail
-
Team tab: role-based queue filtering — assign Senior/Triage/Janitor roles so each mod sees only their slice
-
Settings: custom keyword rules with weight slider — AI content analysis enabled with Claude Haiku integration
-
Queue health alert: red banner fires when HIGH-risk items sit unreviewed — suggested action shows "Likely spam" when reports pile up
-
Insights: signal agreement at 100% — Good catch / Wrong call feedback loop tracking accuracy in real time
ModOS — Full Submission Document
Reddit Mod Tools Hackathon · Best New Mod Tool All submission copy + architecture diagrams in one file.
Inspiration
Meet Priya. She mods r/IndieDevs solo — 47,000 members, one person, no days off.
Every morning she opens Reddit's mod queue: 60+ items, flat list, no ranking, no explanation. Spam posts look identical to genuine questions. A coordinated affiliate campaign sits next to a first-time poster asking for help. Reddit shows everything equally — and she has 20 minutes before her real job starts.
Reddit's mod queue tells you what's waiting. It doesn't tell you what matters.
The sub grows. Two new mods join: Marcus, who handles routine approvals while Priya sleeps in a different timezone, and Aiko, who covers the EU morning rush. Now there's a new problem: they're stepping on each other. Marcus approves something Aiko was reviewing. Priya can't tell if the queue is clear or if something slipped through. There's no shared view, no role separation, no workload visibility.
Then a coordinated spam campaign hits. 8 posts in 12 minutes from the same account. By the time Priya sees the first one, the other 7 are buried in the queue.
ModOS exists because moderation is the only job where the tools assume you work alone, have infinite time, and never get attacked.
What it does
ModOS is a Devvit-native intelligence layer that sits on top of the Reddit mod queue. Install it in your subreddit, open it from mod tools, and you get five tabs that turn reactive chaos into a proactive system.
Triage — Smart, Ranked Queue
Every post and comment gets scored the moment it's submitted using 7 deterministic signals. The queue auto-sorts into High Risk / Medium / Normal / Noise buckets — mods always know where to look first.
Every score is fully explained: plain-English chips like New account (2 days old), Same domain 4× this week, Author burst. No black boxes. A mod can read, agree with, or challenge every flag.
Inline Approve / Remove. Bulk-approve all Normal/Noise items in one click. Queue health alert: red banner fires if a HIGH-risk item has been waiting 30+ minutes — nothing slips through while a mod is offline.
Shield — Coordinated Attack Detection
Every 3 minutes, ModOS scans for author bursts: accounts posting ≥4 times in 15 minutes. When detected, a cluster card appears with a single ⚡ Nuke campaign button that removes every item in the burst as spam in one action. A 10-second response to what used to be a 10-minute cleanup.
With AI enabled, each cluster card shows an AI campaign summary: "8 posts promoting a Telegram crypto investment channel, all using identical shortened links." — so mods know exactly what they're nuking before they tap.
Team — Role-Based Mod Coordination
Assign roles to each mod:
- Senior Mod — HIGH + MEDIUM risk (dangerous items, first line of defence)
- Triage Mod — MEDIUM + NORMAL (judgment calls)
- Janitor Mod — NORMAL + NOISE (bulk approvals, routine backlog)
Each mod's Triage automatically filters to their assigned bucket range. No overlap, no duplicate work. Priya, Marcus, and Aiko each see only what they're responsible for.
The admin dashboard shows every mod's personal stats. A workload balance warning fires when one mod is carrying 2× the team average — so the lead mod can rebalance before anyone burns out.
Settings — Per-Sub Customization
Every subreddit is different. ModOS is fully tunable:
Sensitivity presets (Low / Balanced / High) set baseline thresholds in one tap.
Signal tuning — enable/disable any of the 6 built-in signals per-sub. Override individual weights. A sub about new developers might want to disable "New account" entirely. A crypto sub might crank "Repeat domain" to 80.
Custom keyword signals — teach ModOS what spam looks like in your community. Add a keyword (e.g. t.me/, udemy, a competitor's domain), set a weight and chip label. Every future post containing it fires that signal. Each keyword shows a hit counter (fired: 7×) so you can see whether your teaching is working.
AI content analysis (Claude) — opt-in AI review layer. Enable it in Settings, paste your Anthropic API key once, and every HIGH or MEDIUM item gets reviewed by Claude Haiku in the background (every 5 minutes). Each card gets an AI verdict chip: AI: likely spam, AI: looks legitimate, or AI: uncertain — with a one-sentence reason from Claude alongside the deterministic signals. (Fully implemented; requires Devvit HTTP permission activation — outbound HTTP to api.anthropic.com is blocked in Devvit's current sandbox environment.)
AI keyword suggestions — describe your spam in plain English ("Telegram links promoting crypto courses") and Claude generates 3-5 specific keyword rules with weights and chip labels. Review the suggestions and add them with one click. (Same platform constraint applies.)
All customizations survive preset switches — keywords and weight overrides are preserved when switching sensitivity levels.
Insights — Prove the Value
- Items handled, high-risk surfaced, estimated time saved
- Personal stats per mod (your contribution, not just the team's)
- Signal accuracy based on "Good catch / Wrong call" feedback
- Accuracy recommendations — if a signal is wrong >30% of the time (with ≥5 feedback samples), ModOS tells you to disable or retune it
- Audit log — every approve/remove with timestamp, mod name, bucket, signal chips. Full accountability trail.
Auto-Escalation DMs
If a HIGH-risk item sits unreviewed for 30+ minutes, ModOS sends a Reddit private message to the Senior Mod. The team lead gets paged even when the app isn't open. No repeat spam — each item is marked alerted.
How we built it
Built on Reddit's Developer Platform with one optional external integration.
- Devvit Web (React 19 + Tailwind CSS 4 + Vite) — UI inside a Reddit post
- Hono — lightweight server for triggers, scheduler, and all REST API routes
- Devvit Redis — all state: items, assessments, clusters, config, mod roles, keyword stats, audit log, counters
- Devvit Triggers (PostSubmit, CommentSubmit, PostReport, CommentReport, ModAction, AppInstall)
- Devvit Scheduler — Shield scan + escalation scan (every 3 min) + AI review scan (every 5 min)
- Claude API (opt-in, implemented) — Anthropic's Claude Haiku reviews flagged items for spam. API key stored per-community in Redis. Scheduler processes up to 10 items per run. Verdict + reason stored in
RiskAssessment.aiReview; displayed as a purple chip on each item card.permissions.http.domainsdeclared indevvit.json. Live calls blocked by Devvit's server sandbox in current deployment; outbound HTTP toapi.anthropic.comis not permitted in the playtest/sandbox environment.
Scoring pipeline: Trigger fires → runSignals() evaluates 6 built-in signals + custom keyword rules (7th signal type) → score() sums weighted outputs → bucket assignment → RiskAssessment written to Redis. UI reads from cache instantly.
Role system: mod:roles hash in Redis stores username → ModRole. /api/triage reads calling mod's identity via reddit.getCurrentUser(), maps to role, filters items to that role's allowed buckets.
Per-sub customization: CommunityConfig stores keywords[], signalWeights, disabledSignals. applyPreset() typed as Omit<CommunityConfig, 'keywords'|'signalWeights'|'disabledSignals'> — preset switches only touch threshold fields, preserving all community tuning.
Keyword hit tracking: Each CUSTOM_KEYWORD FiredSignal carries a ruleId. On every trigger, matching rule IDs increment cnt:kw:{ruleId} counters. Settings tab reads these to show live hit counts.
Audit log: Every /api/act call writes an AuditEntry (mod, action, item title, bucket, signal chips) to a Redis list, capped at 200 entries. Insights tab shows the last 20.
Challenges we ran into
No redis.keys. Devvit Redis doesn't support key scanning. Every collection needs an explicit hash index (hSet/hDel/hKeys). This constraint pushed us toward cleaner architecture — O(1) index lookups instead of scan-everything.
Trigger payload quirks. PostV2.createdAt is epoch milliseconds already — not seconds. author.createdAt doesn't exist on the trigger payload; getting account age requires a separate getUserByUsername() call per submit.
Explainability over accuracy. Every signal must produce a human-readable chip and a plain-English clause that stays accurate across edge cases (unknown account age, missing domain, zero-karma new accounts). A signal that fires correctly but can't explain itself is worse than useless for a mod auditing a decision.
Role filtering without locks. Multiple mods viewing the same queue simultaneously need different filtered views without items being "claimed." Roles are filters, not locks. Every item stays in the active index; each mod's view is filtered server-side by their role's allowed buckets.
Preset + customization compatibility. Presets change thresholds cleanly. But communities spend time configuring keywords and signal weights. Typed PRESETS as Omit<CommunityConfig, 'keywords'|'signalWeights'|'disabledSignals'> — preset application only touches threshold fields, preserving all tuning.
Accomplishments we're proud of
- Zero false "black box" scores. Every score is traceable to a named signal with a weight, chip, and reason clause. Mods can always answer "why is this flagged?"
- Nuke campaign. One button removes a coordinated spam burst. 8-item cleanup in under 10 seconds.
- Custom keyword DNA. Subreddits teach ModOS to recognize their own spam patterns — specific domains, competitor mentions, community-specific slang — making the tool genuinely personalized.
- Role coordination that works. Three mods, three queue slices, no overlap. Workload balance warning catches burnout before it happens.
- Signal accuracy feedback loop. Mods rate catches as good or wrong. ModOS computes per-signal accuracy and proactively recommends disabling underperforming signals. The tool improves with use.
- Full audit trail. Every mod action logged with timestamp, mod name, bucket, and signal chips. Production-grade accountability.
- AI layer fully implemented (platform-blocked). The Claude integration — verdict chips, cluster summaries, keyword suggestions — is fully coded, UI-wired, and unit-tested (19 tests). Live calls are blocked by Devvit's server sandbox which does not allow outbound HTTP in the current environment.
permissions.http.domains: ["api.anthropic.com"]is declared indevvit.json; the feature is ready to activate when Devvit enables the HTTP permission for this app.
What we learned
Reddit's trigger system is fast but write-only — you can't do multi-item cross-referencing inside a trigger. This forced a clean separation: triggers score one item at a time and write atomically; the scheduler handles cross-item analysis asynchronously. That constraint produced a more resilient architecture than we'd have built otherwise.
The hardest UI problem in moderation tools isn't showing information — it's hiding it. The noise bucket collapse, role-filtered triage view, signal tuning panel — all about giving mods exactly what they need for their job, nothing more.
Devvit's server sandbox blocks all outbound HTTP — global fetch routes through a gRPC proxy that denies unlisted domains even after declaring permissions.http in devvit.json. Discovering this during live testing meant the Claude integration, though fully implemented and unit-tested, couldn't be demonstrated live. This is a platform constraint we're flagging to the Devvit team.
What's next for ModOS
- Modmail escalation — auto-escalation to subreddit modmail for full team visibility (instead of single DM)
- Rule builder — drag-and-drop signal combinator: "if NEW_ACCOUNT + REPEAT_DOMAIN → always HIGH"
- Confidence-threshold auto-actions — when AI + deterministic signals both exceed a threshold, auto-remove with mod review window
- AI removal reason drafting — when removing a HIGH confidence item, Claude drafts the removal reason message for the mod to review and send
- Weekly intelligence brief — scheduled AI summary: top spam patterns, keyword performance, threat trends
- MCP server — a companion MCP server would let mods query their ModOS queue directly from Claude chat — we're exploring this as a post-launch extension once Devvit supports persistent webhook endpoints
- Cross-community signal sharing — same domain spamming 40 subs simultaneously gets flagged for all participating mods as a network. (Concept — not functional in v1; requires cross-community Redis infrastructure)
Built with
Devvit · React 19 · TypeScript · Tailwind CSS 4 · Vite · Hono · Devvit Redis · Claude API (Anthropic)
Technical Architecture
System Overview
REDDIT PLATFORM
|
PostSubmit / CommentSubmit / Report / ModAction
|
v
+------------------+ +--------------------+ +----------------------+
| Devvit Triggers | | Devvit Scheduler | | Devvit Redis |
| | | | | |
| PostSubmit | | shield-scan | | item:{id} |
| CommentSubmit | | every 3 min | | assess:{id} |
| PostReport | | -> burst clusters | | active:items (Hash)|
| CommentReport | | | | active:clusters (H) |
| ModAction | | escalation-scan | | cluster:{id} |
| AppInstall | | every 3 min | | win:author:* (ZSet)|
+------------------+ | -> DM senior mods | | win:domain:* (ZSet)|
| | | | win:texthash: (ZSet)|
| | ai-review-scan | | cfg, mod:roles |
| | every 5 min | | cnt:*, feedback:* |
| | -> Claude Haiku | | audit:log (List) |
| score + write| -> update assess | | cnt:kw:* |
| +--------------------+ | alerted:items (Hash)|
| | | ai:pending (Hash)|
+-----------+-----------+ +----------+-----------+
v |
| |
+-------- reads / writes ---------------+
|
v
+----------------------------------------------------------------------+
| Hono Server |
| |
| /internal/triggers/* score + store to Redis |
| /internal/scheduler/* burst detection + escalation DMs |
| /internal/menu/* create post / seed demo data |
| |
| /api/triage ranked queue, filtered by mod role |
| /api/act approve / remove + write audit entry |
| /api/insights stats + personal dashboard + audit log |
| /api/clusters/* burst clusters + nuke campaign |
| /api/mod-roles team role assignment (GET + POST) |
| /api/config sensitivity preset + re-score all active |
| /api/keywords/* custom keyword CRUD + hit counters |
| /api/signals/* per-signal weight override + toggle |
| /api/feedback record good-catch / wrong-call |
| /api/audit last 20 mod actions |
+----------------------------------------------------------------------+
|
fetch /api/*
|
v
+----------------------------------------------------------------------+
| ModOS React UI (Devvit Web Post) |
| |
| [Triage] [Shield] [Insights] [Settings] [Team] |
| |
| Triage : ranked buckets, role filter, queue health alert |
| Shield : burst cluster cards, nuke campaign button |
| Insights : stats grid, personal stats, audit log, recs |
| Settings : presets, signal tuning, custom keywords + hit counts |
| Team : role assignment, workload balance, per-mod dashboard |
+----------------------------------------------------------------------+
Directory Structure
modos23/
├── devvit.json <- manifest: triggers, scheduler, menu, entrypoints
├── vite.config.ts <- build: client -> dist/client, server -> dist/server/index.cjs
│
├── src/
│ ├── shared/
│ │ ├── types.ts <- ALL types: RawItem, RiskAssessment, CommunityConfig,
│ │ │ AuditEntry, KeywordRule, ModRole, Recommendation...
│ │ └── api.ts <- every API request/response shape
│ │
│ ├── server/
│ │ ├── index.ts <- Hono entry: mounts /api + /internal
│ │ ├── redis.ts <- all Redis key helpers + typed wrappers (no magic strings)
│ │ ├── windows.ts <- ZSet rolling-window: push, prune, count, getItems
│ │ ├── score.ts <- scoreItem(): signals -> weights -> bucket + sentence
│ │ ├── actions.ts <- reddit.approve() / reddit.remove() with TID handling
│ │ ├── config.ts <- PRESETS + applyPreset() (set config + re-score all active)
│ │ ├── insights.ts <- getInsights(): counters -> InsightsSnapshot + recommendations
│ │ ├── ai.ts <- analyzeContent() + suggestKeywords() via Claude Haiku API
│ │ ├── queue.ts <- getQueue(): raw Reddit mod queue fetch (AppInstall backfill)
│ │ ├── seed.ts <- runSeed(): 25 mock items for demo mode
│ │ │
│ │ ├── signals/
│ │ │ ├── index.ts <- runSignals(): disabled check, weight overrides, custom kw
│ │ │ ├── newAccount.ts <- NEW_ACCOUNT weight 30
│ │ │ ├── lowTrust.ts <- LOW_TRUST weight 25
│ │ │ ├── highReports.ts <- HIGH_REPORTS weight 40
│ │ │ ├── repeatedDomain.ts <- REPEATED_DOMAIN weight 35
│ │ │ ├── repeatedText.ts <- REPEATED_TEXT weight 40
│ │ │ ├── authorBurst.ts <- AUTHOR_BURST weight 50
│ │ │ └── customKeyword.ts <- CUSTOM_KEYWORD weight configurable (10-60)
│ │ │
│ │ └── routes/
│ │ ├── api.ts <- all /api/* REST endpoints
│ │ ├── triggers.ts <- all /internal/triggers/*
│ │ ├── scheduler.ts <- shield-scan + escalation-scan
│ │ ├── menu.ts <- /internal/menu/* (create post, load demo)
│ │ └── forms.ts <- /internal/form/*
│ │
│ └── client/
│ ├── game.tsx <- app shell: tab state, toast stack, action handlers
│ ├── Triage.tsx <- bucket groups, role banner, queue health alert, bulk approve
│ ├── Shield.tsx <- cluster cards, nuke campaign button
│ ├── Insights.tsx <- stats, personal dashboard, audit log, recommendations
│ ├── Settings.tsx <- presets, signal tuning, custom keywords + hit counters
│ ├── Team.tsx <- role assignment, workload balance warning, per-mod stats
│ ├── splash.tsx <- install landing page
│ ├── components/
│ │ └── ItemCard.tsx <- full card: chips, sentence, approve/remove, feedback
│ └── hooks/
│ ├── useTriage.ts <- fetches /api/triage, exposes role + todayStats
│ └── useClusters.ts <- fetches /api/clusters, dismiss()
End-to-End Flow: Post Submitted -> Mod Acts
USER SUBMITS POST
|
| Devvit onPostSubmit fires
v
1. Parse payload (post, author)
2. reddit.getUserByUsername(author.name) -> authorCreatedAt, karma
3. Build RawItem { id, kind, authorName, authorCreatedAt, authorKarma,
title, body, domain, numReports, createdAt, permalink }
|
v
4. getConfig() -> CommunityConfig (preset thresholds + custom keywords)
5. Push + prune rolling windows (parallel):
pushWindow('author', authorName, id, createdAt)
pruneWindow('author', authorName, cutoff) cutoff = now - windowMin
pushWindow('domain', domain, id, createdAt) <- link post only
pushWindow('texthash', hash, id, createdAt) <- if text present
6. Count window sizes (parallel):
authorCount = zCard('win:author:{name}')
domainCount = zCard('win:domain:{domain}')
textCount = zCard('win:texthash:{hash}')
|
v
7. runSignals(item, cfg, { authorCount, domainCount, textCount })
SYNC signals (item fields only):
NEW_ACCOUNT -> authorCreatedAt < cfg.newAccountDays * 86400000
LOW_TRUST -> authorKarma < cfg.karmaFloor
HIGH_REPORTS -> numReports >= cfg.reportFloor
WINDOW signals (need WindowContext):
REPEATED_DOMAIN -> domainCount >= 3
REPEATED_TEXT -> textCount >= 2
AUTHOR_BURST -> authorCount >= cfg.burstFloor
CUSTOM_KEYWORD signals:
for each rule in cfg.keywords[]:
if title+body contains rule.keyword (case-insensitive):
fire { id:'CUSTOM_KEYWORD', weight:rule.weight, chip:rule.chip, ruleId:rule.id }
Weight overrides: if cfg.signalWeights[signal.id] -> override weight
Disabled signals: if cfg.disabledSignals includes signal.id -> skip
8. score = sum of weights of all fired signals
9. bucket:
score >= highCutoff -> HIGH
score >= highCutoff * 0.5 -> MEDIUM
score >= 10 -> NORMAL
score < 10 -> NOISE
10. sentence = "Flagged because {clause1}, {clause2}, and {clause3}."
11. suggestedAction = review_now | likely_spam | needs_judgment | probably_safe
|
v
WRITE TO REDIS (parallel):
set('item:{id}', JSON(RawItem))
set('assess:{id}', JSON(RiskAssessment))
hSet('active:items', { [id]: '1' })
if bucket==='high' -> incrBy('cnt:highRisk', 1)
for each fired signal -> incrBy('cnt:reason:{signalId}', 1)
for CUSTOM_KEYWORD signals with ruleId:
incrBy('cnt:kw:{ruleId}', 1)
set('cnt:kw:{ruleId}:last', now)
|
v (item now in Redis queue)
|
+-------------------+----------------------------+
| | |
v v v
MOD OPENS MODOS SHIELD SCAN (3 min) ESCALATION SCAN (3 min)
| | |
GET /api/triage group items by author find HIGH items > 30 min old
- reddit.getCurrentUser() check alerted:items hash
- getModRole(modName) DM Senior Mods via reddit.sendPrivateMessage()
- filter by ROLE_BUCKETS[role] mark items in alerted:items hash
- fetch item + assess pairs
- sort by score DESC
- return { items, modName, role, todayStats }
|
v
React renders Triage tab:
- HIGH cards at top
- each card: chips + sentence + feedback buttons
- inline Approve / Remove
- queue health alert if HIGH item > 30 min old
|
v
MOD TAPS "REMOVE"
1. dismissItem(id) <- instant local state update
2. POST /api/act { id, kind, action:'remove' }
- reddit.remove(T1/T3 TID) <- actual Reddit API call
- hDel('active:items', [id]) <- remove from queue index
- incrBy('cnt:handled', 1)
- recordModAction(modName, wasHighRisk) <- per-mod counter
- appendAudit({ ts, modName, action, itemId, title, bucket, chips })
3. toast "Removed"
Shield: Burst Detection Flow
Every 3 minutes: POST /internal/scheduler/shield-scan
|
+- hKeys('active:items') -> all active IDs
+- getItem() for each -> items[]
+- group by authorName -> Map<author, itemIds[]>
|
+- for each author with >= burstFloor active items:
| zCard('win:author:{name}') -> count in rolling window
| if count >= burstFloor:
| getWindowItems('author', name) -> all itemIds in window
| mark author BURSTY
|
+- upsert Cluster for each bursty author:
| id: 'burst:{authorName}' <- deterministic, safe to upsert
| label: 'u/foo: 4 posts in 15 min'
| hSet('active:clusters', { ['burst:{author}']: '1' })
|
+- remove stale clusters (burst subsided + not dismissed):
hDel('active:clusters', [id])
del('cluster:{id}')
MOD TAPS "NUKE CAMPAIGN":
POST /api/clusters/nuke { id }
- listClusters() -> find cluster by id
- for each itemId in cluster.itemIds:
getItem(id) -> RawItem
removeItem(id, kind, isSpam=true) <- spam-flagged removal
recordModAction(modName, true) <- counts as high-risk handled
- removeCluster(id)
- return { status:'ok', removed: N }
toast: "Campaign nuked — 8 items removed as spam"
NOTE: AUTHOR_BURST signal fires INLINE on the 4th post (step 7 above)
before the scanner runs. Scanner is the grouping + display layer.
Escalation DM Flow
Every 3 minutes: POST /internal/scheduler/escalation-scan
|
+- listActive() -> all active item IDs
+- for each ID:
| getAssessment(id) -> bucket
| isAlerted(id) -> already DMed?
| getItem(id) -> createdAt
| if bucket==='high' AND now - createdAt > 30min AND NOT alerted:
| staleHighIds.push(id)
|
+- if staleHighIds.length === 0 -> return (nothing to do)
|
+- getModRoles() -> Record<modName, ModRole>
+- seniorMods = entries where role === 'senior'
+- if seniorMods.length === 0 -> return (no one to DM)
|
+- reddit.sendPrivateMessage({
| to: seniorMod,
| subject: 'ModOS: Stale HIGH-risk items need review',
| text: '3 HIGH-risk items waiting 30+ min in mod queue'
| })
|
+- markAlerted(itemId) for each item <- hSet('alerted:items', { [id]: '1' })
prevents duplicate DMs on next scan run
Role-Based Queue Filtering
GET /api/triage
|
+- reddit.getCurrentUser() -> modName (or 'unknown')
+- getModRole(modName) -> ModRole from mod:roles hash
|
| ROLE_BUCKETS:
| senior -> ['high', 'medium']
| triage -> ['medium', 'normal']
| janitor -> ['normal', 'noise']
| all -> ['high', 'medium', 'normal', 'noise']
|
+- listActive() -> all item IDs (shared pool)
+- for each ID: getItem + getAssessment
+- filter: item.assessment.bucket IN ROLE_BUCKETS[role]
+- sort by score DESC
+- return { items, modName, role, todayStats }
Result: three mods open ModOS simultaneously.
Priya (senior) sees: 4 HIGH + 3 MEDIUM items
Marcus (janitor) sees: 12 NORMAL + 8 NOISE items
Aiko (triage) sees: 3 MEDIUM + 12 NORMAL items
No overlap. No duplicate work. Same Redis pool, different filtered views.
WORKLOAD BALANCE WARNING (client-side):
if any mod.handled > avg(allMods.handled) * 2:
show amber warning banner in Team tab:
"u/{name} is handling 3x the team average. Consider reassigning role."
Redis Key Schema
ITEMS INDEXES (hash, field=id, value='1')
------------------------------ ------------------------------------
item:{id} String/JSON active:items Hash
assess:{id} String/JSON active:clusters Hash
known:mods Hash
CLUSTERS alerted:items Hash
------------------------------
cluster:{id} String/JSON ROLLING WINDOWS (ZSet, score=ts ms)
------------------------------------
CONFIG + ROLES win:author:{name}
------------------------------ win:domain:{domain}
cfg String/JSON win:texthash:{hash}
mod:roles String/JSON
COUNTERS FEEDBACK
------------------------------ ------------------------------------
cnt:highRisk int feedback:{signalId}:good int
cnt:handled int feedback:{signalId}:bad int
cnt:clusters int
cnt:reason:{id} int KEYWORD HIT COUNTERS
cnt:handled:{mod} int ------------------------------------
cnt:highRisk:{mod} int cnt:kw:{ruleId} int
cnt:kw:{ruleId}:last string (ts)
AUDIT LOG
------------------------------
audit:log List (max 200 entries, RPUSH + LTRIM)
| Key | Type | Written by | Read by |
|---|---|---|---|
item:{id} |
String | PostSubmit trigger | GET /api/triage |
assess:{id} |
String | PostSubmit, PostReport | GET /api/triage |
active:items |
Hash | addActive / removeActive | listActive -> hKeys |
active:clusters |
Hash | addCluster / removeCluster | listClusters -> hKeys |
cluster:{id} |
String | Scheduler shield-scan | GET /api/clusters |
win:author:{name} |
ZSet | Every submit trigger | Score pipeline |
mod:roles |
String | POST /api/mod-roles | GET /api/triage |
cnt:handled:{mod} |
String | POST /api/act | GET /api/mod-roles |
audit:log |
List | POST /api/act | GET /api/audit |
cnt:kw:{ruleId} |
String | PostSubmit (if keyword fires) | GET /api/keywords/stats |
alerted:items |
Hash | escalation-scan | escalation-scan |
Why hashes not sets: Devvit Redis has no SADD/SREM/SMEMBERS. Hash with constant value '1' per field is the equivalent.
Why ZSets for windows: score = createdAt (ms) lets us prune with zRemRangeByScore(key, 0, cutoff) and count with zCard. No manual TTL management.
Signal Pipeline
RawItem + CommunityConfig + WindowContext
|
v
runSignals()
|
+-- check cfg.disabledSignals -> skip disabled signals
|
+-- SYNC signals (pure item data)
|
| NEW_ACCOUNT weight 30 (overridable)
| fires: authorCreatedAt > 0
| AND now - authorCreatedAt < newAccountDays * 86400000
| chip: "New account"
| clause: "the account is only {N} days old"
|
| LOW_TRUST weight 25 (overridable)
| fires: authorKarma > 0 AND authorKarma < karmaFloor
| chip: "Low karma"
| clause: "the author has only {N} karma"
|
| HIGH_REPORTS weight 40 (overridable)
| fires: numReports >= reportFloor
| chip: "{N} reports"
| clause: "received {N} community reports"
|
+-- WINDOW signals (need WindowContext)
|
| REPEATED_DOMAIN weight 35 (overridable)
| fires: item.domain AND domainCount >= 3
| chip: "Repeat domain"
| clause: "links to a domain seen {N} times recently"
|
| REPEATED_TEXT weight 40 (overridable)
| fires: textCount >= 2
| text normalized: lowercase + collapse whitespace + djb2 hash
| chip: "Duplicate text"
| clause: "uses text identical to {N} other recent posts"
|
| AUTHOR_BURST weight 50 (overridable)
| fires: authorCount >= burstFloor
| chip: "Author burst"
| clause: "the author has posted {N} times recently"
|
+-- CUSTOM_KEYWORD signals (per-sub rules from CommunityConfig)
for each rule in cfg.keywords[]:
if (title + body).toLowerCase().includes(rule.keyword.toLowerCase()):
fire { id:'CUSTOM_KEYWORD', weight:rule.weight,
chip:rule.chip, ruleId:rule.id }
incr cnt:kw:{rule.id} (async, in trigger handler)
|
v
apply cfg.signalWeights overrides to each fired signal
|
v
FiredSignal[] -> scoreItem() -> RiskAssessment
Scoring Model
score = sum of weights of all fired signals
Examples (balanced preset, highCutoff = 60):
LOW_TRUST(25) = 25 -> NORMAL (>= 10)
NEW_ACCOUNT(30) = 30 -> MEDIUM (>= 30 = highCutoff * 0.5)
NEW_ACCOUNT(30) + LOW_TRUST(25) = 55 -> MEDIUM (< 60)
NEW_ACCOUNT(30) + HIGH_REPORTS(40) = 70 -> HIGH (>= 60)
HIGH_REPORTS(40) + AUTHOR_BURST(50) = 90 -> HIGH
NEW_ACCOUNT(30) + HIGH_REPORTS(40) + AUTHOR_BURST(50) = 120 -> HIGH
custom keyword(35) + NEW_ACCOUNT(30) = 65 -> HIGH
Bucket thresholds (highCutoff = 60):
0 ---- 10 --------- 30 ----------- 60 --------------------->
NOISE | NORMAL | MEDIUM | HIGH
< 10 | >= 10 | >= 30 | >= 60
medium threshold = highCutoff * 0.5
Preset Comparison
| Preset | Account age | Karma floor | Reports | High cutoff | Window | Burst |
|---|---|---|---|---|---|---|
| Low | < 7 days | < 10 | >= 5 | 80 | 15 min | 6 posts |
| Balanced | < 30 days | < 50 | >= 3 | 60 | 15 min | 4 posts |
| High | < 90 days | < 100 | >= 1 | 40 | 30 min | 2 posts |
All presets preserve: keywords[], signalWeights, disabledSignals
API Reference
| Method | Path | Description |
|---|---|---|
| GET | /api/triage |
Active items, filtered by mod role, sorted by score |
| GET | /api/clusters |
Active burst clusters |
| GET | /api/insights |
Stats + personal dashboard + audit log |
| GET | /api/config |
Current community config |
| GET | /api/mod-roles |
All role assignments + per-mod stats |
| GET | /api/keywords/stats |
Hit counters per keyword rule |
| GET | /api/audit |
Last 20 mod actions |
| POST | /api/act |
Approve or remove (calls Reddit API + writes audit entry) |
| POST | /api/config |
Apply sensitivity preset, re-score all active items |
| POST | /api/feedback |
Record good-catch / wrong-call on an item |
| POST | /api/clusters/dismiss |
Remove cluster from active set |
| POST | /api/clusters/nuke |
Mass-remove all cluster items as spam |
| POST | /api/mod-roles |
Assign role to a mod |
| POST | /api/keywords/add |
Add custom keyword rule |
| POST | /api/keywords/remove |
Remove keyword rule by ID |
| POST | /api/keywords/ai-suggest |
Generate keyword rules from natural language description |
| POST | /api/signals/weight |
Override signal weight (or null to reset) |
| POST | /api/signals/toggle |
Enable or disable a signal |
| POST | /api/ai/config |
Set aiEnabled flag + store Anthropic API key |
Trigger Reference
| Trigger | Action |
|---|---|
onAppInstall |
Create pinned post + backfill up to 25 existing queue items |
onPostSubmit |
Full processItem(): enrich from Reddit API, score, write to Redis |
onCommentSubmit |
Same as PostSubmit, comment-type RawItem |
onPostReport |
Re-score existing item with updated numReports |
onCommentReport |
Same as PostReport for comments |
onModAction |
Auto-remove from active index on terminal actions |
Terminal mod actions that sync ModOS queue:
removelink approvelink spamlink
removecomment approvecomment spamcomment
Platform Constraints & Solutions
| Constraint | Solution |
|---|---|
No redis.keys or redis.scan |
Hash index per collection: hSet/hDel/hKeys |
No sAdd/sRem/sMembers |
Hash with constant value '1' per field |
ZSet member shape is { score, member } |
All window code uses object shape |
PostV2.createdAt is already epoch ms |
No x1000 multiplication anywhere |
author.createdAt not on trigger payload |
getUserByUsername() call on every submit |
reddit.approve/remove needs T3/T1 TID |
toTid(id, kind) wraps both |
No showToast in @devvit/web/client |
React state-based toast stack in game.tsx |
| Triggers must be cheap + write-only | Score one item, write Redis, return. No cross-item work. |
| Scheduler jobs must be idempotent | Cluster IDs are burst:{authorName} — safe to upsert |
| Role filtering without item locking | Roles are server-side filters, not locks on items |
Type System at a Glance
// Item flowing through the system
RawItem -> scoreItem() -> RiskAssessment -> ScoredItem (merged for client)
// Signal evaluation
(RawItem + CommunityConfig + WindowContext) -> FiredSignal[]
FiredSignal = { id: SignalId, weight, chip, clause, ruleId? }
^-- only for CUSTOM_KEYWORD
// Buckets
'high' | 'medium' | 'normal' | 'noise'
// Signal IDs (all deterministic, no ML)
'NEW_ACCOUNT' | 'LOW_TRUST' | 'HIGH_REPORTS'
'REPEATED_TEXT' | 'REPEATED_DOMAIN' | 'AUTHOR_BURST' | 'CUSTOM_KEYWORD'
// Mod roles
'senior' | 'triage' | 'janitor' | 'all'
// Audit entry (every mod action logged)
AuditEntry = { ts, modName, action, itemId, title?, bucket, signalChips[] }
// Community config (fully customizable per sub)
CommunityConfig = {
preset, newAccountDays, karmaFloor, reportFloor,
highCutoff, windowMin, burstFloor, // threshold fields (preset-controlled)
keywords: KeywordRule[], // custom keyword signals
signalWeights: Partial<Record<SignalId>>, // per-signal weight overrides
disabledSignals: SignalId[], // signals to skip entirely
}
Test Coverage
All deterministic logic is unit-tested with Vitest. Tests run with npm test (no mocking, no network calls).
Test Files 12 passed (12)
Tests 113 passed (113)
Duration ~500ms
| File | Tests | What it covers |
|---|---|---|
signals/newAccount.test.ts |
7 | Unknown age skip, threshold boundary, day-label accuracy, custom threshold |
signals/lowTrust.test.ts |
6 | At/above/below karma floor, zero-karma skip |
signals/highReports.test.ts |
6 | Singular/plural chip, report floor boundary, custom floor |
signals/repeatedDomain.test.ts |
5 | No domain, count <3 skip, count ≥3 fire |
signals/repeatedText.test.ts |
4 | Count <2 skip, count ≥2 fire |
signals/authorBurst.test.ts |
5 | burstFloor boundary, author name in clause |
signals/customKeyword.test.ts |
8 | Case-insensitive, title+body match, ruleId in output, multi-keyword stacking |
score.test.ts |
24 | All 4 buckets, all suggestedAction branches, sentence construction, weight overrides, disabled signals, preset thresholds, output structure |
score.boundary.test.ts |
14 | Exact threshold edges (scores 9/10/29/30/59/60), all 7 signals simultaneous (score=255), preset comparison, keyword stacking |
windows.test.ts |
9 | textHash: determinism, case normalization, whitespace/tab/newline collapse |
ai.test.ts |
19 | parseVerdictResponse + parseKeywordSuggestions: all verdicts, malformed input, cap at 5, prose wrapping |
redis.keys.test.ts |
10 | All 25 Redis key helpers produce correct strings; no two static keys collide |
What's not unit-tested: Redis wrappers (require mocking Devvit SDK), route handlers, React components. These are covered by live playtest on the test subreddit r/modos23_dev.
Built for the Reddit Mod Tools Hackathon · Best New Mod Tool No required external services. Every flag has a reason. Every action is one tap.
Built With
- devvit-redis
- devvit-web
- hono
- react-19
- tailwind-css-4
- typescript
- vite
- vitest
Log in or sign up for Devpost to join the conversation.