Inspiration

I have watched subreddit moderators burn out in slow motion. Queues pile up at 3 AM. Brigades land before anyone is awake. Filter rules drift out of date. The "AI moderation" market answers this by piping every post to a third-party API, hiding decisions in a black box, and charging per call.

I wanted the opposite: a tool that runs inside Reddit, shows its work, and learns from the mods who use it, without ever asking for an API key. Devvit's serverless runtime made that newly possible. AgenticMod is what that looks like end to end.

The guiding principle came from a conversation with a mod who said: "I don't want the bot to be smarter than me. I want it to be faster than me on the obvious stuff and honest with me about the rest." That sentence is essentially the architecture.

What I Learned

1. Devvit Blocks is a hard ceiling, until you embrace it

Devvit Blocks is a deliberately tiny SwiftUI-style DSL: only vstack / hstack / zstack, text, button, image, icon, spacer. No CSS, no shadows, no animations, no flex-wrap. I burned hours fighting it before realising the right move is to use the constraints: build a real progress bar from a zstack of two rectangles, fake an accent stripe by composing a 4-px-wide colored vstack next to the content, fake a card border with border: 'thin' plus a token color. The constraint forces you toward a minimal, accessible visual language.

2. Async event handlers are a re-render trap

Devvit's re-render cycle is driven by awaiting the promise returned from onPress. My first emergency-controls implementation wrapped the click handler in () => { void applyState(...) }. The Redis write succeeded, the dashboard never updated. The fix was passing the async function directly so Devvit could await it. This was the single most expensive bug-hunt of the project, and it taught me that the framework's contract is "give me a thenable, I'll re-render when it settles."

3. Settings are read-only at runtime, design around it

Devvit settings can only be edited via the App Settings UI. The "Emergency stop button" I wanted required a Redis-backed override that the runtime checks before falling back to the setting:

$$ \text{effectiveState} = \text{redisOverride} \;\lor\; \text{devvitSetting} \;\lor\; \text{ACTIVE} $$

That single composition, redisOverrideStateSource(redis, settingsStateSource(settings)), is what makes the dashboard buttons real.

4. Confidence-based moderation is actually simple

First, I made the moderation router too complicated. But in reality, every moderation check just gives a score between 0 and 1.

All the scores are combined into one final confidence score c.

Then the system only needs 3 simple rules:

  • low score → allow post
  • medium score → send to mod queue
  • high score → remove post

That’s the whole router.

The hard part is making the final score reliable and making moderation actions easy to undo or review.

$$ \text{decision}(c) = \begin{cases} \text{REMOVE}, & c \ge \tau_{\text{remove}} \ \text{ESCALATE}, & \tau_{\text{escalate}} \le c < \tau_{\text{remove}} \ \text{SAFE}, & c < \tau_{\text{escalate}} \end{cases} $$

5. Adaptive fusion is just small weight updates

When a moderator overrides a decision, the system learns from it.

Each signal has a weight, and the app slightly adjusts those weights based on moderator feedback.

Over time, the moderation system slowly adapts to each subreddit’s preferences without needing full AI retraining.

It stays:

  • simple
  • transparent
  • easy to audit
  • easy to explain to moderators

$$ w_i \leftarrow \mathrm{clip}!\left(w_i + \eta\,(y - \hat{y})\,s_i,\;\; w_{\min},\;w_{\max}\right), \quad \sum_i w_i = 1 $$

6. The serverless runtime is hostile to ML packages

@xenova/transformers looks great on paper but cannot load on Devvit: ONNX Runtime needs .node native bindings, the worker has no FS for model cache, and the bundle-size cap is brutal. I built a ModelBundle adapter so the heuristic backend is the always-on default and transformers is opt-in via a runtime-built dynamic specifier the bundler cannot see. The lesson: design for the worst-case backend first.

7. 100% coverage is cheap if you keep files small

A hard rule of "<= 200 LoC per file" pushed me toward many small modules with narrow contracts. Coverage stays at 100% almost by accident, because each file has one job and the test for that job is obvious. Big files attract un-tested branches the way magnets attract iron filings.

How I Built It

Architecture in one diagram

Reddit (Devvit triggers)
        |
        v
+- moderateContent ------------------------------------------+
|  EmergencyStop = redisOverride(settings)                   |
|  if state == PAUSED -> return                              |
|                                                            |
|  +------------------------------------------------------+  |
|  |  Content Pipeline                                    |  |
|  |  Blacklist -> Toxicity -> Cluster -> Context ->      |  |
|  |  Reasoner                                            |  |
|  |           |                                          |  |
|  |           v                                          |  |
|  |  Adaptive Fusion -> Decision Router -> Safe-Mode     |  |
|  |  Guard                                               |  |
|  +------------------------------------------------------+  |
|                                                            |
|  Explainability log -> Redis (7-day TTL)                   |
|  if requiresHuman: queue + modmail + reddit.report         |
|  if ACTIVE & REMOVE: reddit.remove + author DM             |
|  if ACTIVE & SAFE:   reddit.approve                        |
+------------------------------------------------------------+
        |
        v
Mod replies APPROVE / DENY / ESCALATE  ->  Feedback Learner  ->  weight nudge
        |
        v
In-Reddit Dashboard (custom post): Header, Emergency Controls,
Decisions, Pending, Models, Stats

Stack

Layer Choice Why
Platform Devvit (Reddit Developer Platform) The only way to embed a tool in Reddit's UI
Language TypeScript strict, ES modules Correctness on a small surface
Storage Devvit Redis The only persistent store available in-runtime
Models (default) Heuristic (keyword + 3-gram hash + rule reasoner) Always loads, sub-millisecond, deterministic
Models (opt-in) @xenova/transformers ONNX (toxic-bert, bge-small-en-v1.5) When the runtime can hold them
Tests Vitest + V8 coverage Fast, no Jest config drift
Local dev Docker compose (redis + Express simulator) Reproduce the entire system off-Reddit

Discipline that paid off

  • Every source file under 200 LoC (enforced by scripts/check-loc.cjs).
  • 100% statement / branch / function / line coverage enforced.
  • No any in production code; adapter interfaces over implementation imports.
  • Redis access isolated in src/storage/; everything else is pure functions.
  • Devvit-only entry file (main.ts) is wiring only, zero business logic.

A representative slice: emergency controls

The end-to-end flow for the "PAUSE the agent from inside Reddit" feature:

  1. Storage. AgentStateStore writes a typed value to agentic-mod:agent-state:override.
  2. Engine. redisOverrideStateSource(redis, fallback) consults the override before the Devvit setting.
  3. Pipeline. moderateContent checks state at the very top; PAUSED returns before any model load or Reddit call.
  4. UI. Dashboard renders three buttons; clicking opens a confirmation card with a "what this does" effects list; the Confirm button is destructive when the target is PAUSED.
  5. Feedback. Toast fires before I/O so the click feels instant; setters batch into a single re-render so the dashboard reflects the change immediately.

That single feature touched 7 files and gained 14 tests. Coverage stayed at 100%.


Challenges I Faced

Challenge 1: The dashboard "did nothing" on click

First emergency-controls iteration: the button's onPress fired, the toast appeared, the Redis key changed, and the dashboard stayed stuck on the pre-click snapshot. Cause: I had wrapped the async handler in a sync () => { void apply() }. Fix: pass async () => { await apply() } directly so Devvit awaits it before re-rendering.

Challenge 2: PAUSED was not really paused

applySafeMode only downgraded REMOVE to REVIEW. The pipeline still analysed content, wrote pending items, sent modmail, called reddit.report, and reddit.approve'd safe items. The dashboard advertised PAUSED as "emergency stop" while the bot quietly kept working. Fix: short-circuit at the top of moderateContent for PAUSED, and gate the auto-approve block on state === 'ACTIVE' for SAFE_MODE. The behavior matrix is now:

State Analyse Modqueue + modmail reddit.remove reddit.approve
ACTIVE yes yes yes yes
SAFE_MODE yes yes no no
PAUSED no no no no

Challenge 3: reddit.report is silently a no-op for raw IDs

ESCALATE items never appeared in the modqueue's Reported tab. Devvit's reddit.report requires the resolved Post or Comment object; passing a thing-id is a silent no-op. Fix: getPostById / getCommentById first, then report. One line of context, three days to spot.

Challenge 4: Transformers.js does not load on Devvit

ONNX Runtime needs native .node binaries; the bundler tries to inline them; the bundle exceeds the size cap; if it gets through, the worker cannot load .node at runtime anyway. Fix: dynamic import via a runtime-constructed specifier the static bundler cannot follow, plus an automatic fallback to the heuristic backend. Devvit users get the heuristic; local-sim users get the real model.

Challenge 5: Devvit Blocks renders nothing pretty by default

First version of the dashboard looked like a 1996 web form. Fix sequence: real progress bars from stacked rects, then colored accent stripes via composed vstacks, then stat-tile grid via grow: true siblings, then collapsible panels via toggling useState<boolean>. End result is information-dense and at-a-glance scannable, all within the platform's primitives.

Where it ended up

  • 522 tests, 100% coverage (statements / branches / functions / lines).
  • 67 source files, every one under 200 LoC.
  • TypeScript strict, zero any in production code.
  • Live in-Reddit dashboard with five collapsible panels, mod-only emergency controls, and a confirmation flow for state changes.
  • Two interchangeable model backends behind a single adapter; heuristic is always-on, transformers is opt-in.
  • Zero external network calls in the production path.

If a mod can install AgenticMod, drop a dashboard, and pause the entire agent in under sixty seconds without leaving Reddit, and trust why every decision was made, the project did its job.

Built With

Share this project:

Updates