Build a Reddit Bot on Devvit: A Working Guide
Devvit is Reddit's developer platform: your bot runs on Reddit's serverless infrastructure. Their scheduler fires it, their Redis stores its state, and mods install it like an app instead of tolerating it like a script. This guide builds Link Necromancer, a bot that finds dead links in valued threads and replies with Wayback Machine snapshots, plus a live dashboard that ships as a native Reddit post.
This is a simulation of a scan pass. The real console runs inside Reddit: the app serves it as a pinned dashboard post, reading live events from its own Redis (Step 6).
Why Devvit Changes the Shape of a Bot
A classic Reddit bot is a script you host somewhere, hitting the public API with an account's credentials. A Devvit app inverts all of that: it's TypeScript that Reddit hosts, invoked by platform events and a built-in cron scheduler, with per-installation Redis for state and the Reddit API available without you managing tokens. Mods install it on their subreddit from the app directory, so installation is the approval step.
Reddit scheduler (cron in devvit.json) │ ▼ POST /internal/scheduler/scan reads top posts + comments │ │ arbitrary URLs ──▶ link-check proxy (tiny Worker, Step 4) │ ▼ Redis ◀── events · dedupe map · cursor ▲ │ GET /api/events (same-origin) Dashboard webview — a custom post created from the mod menu
Getting started is three commands:
npm install -g devvit
devvit login # OAuth handshake in the browser
devvit new necromancer
devvit.json Is the Whole Contract
One config file declares everything the platform needs to know: where your server bundle lives, which endpoints handle which events, the cron schedule, and (crucially) every capability your app is allowed to use. There is no ambient permission; if it isn't declared here, your code can't do it.
{
"server": { "dir": "dist/server", "entry": "index.js" },
"scheduler": {
"tasks": {
"scan-pass": { "endpoint": "/internal/scheduler/scan", "cron": "*/30 * * * *" }
}
},
"menu": {
"items": [{ "label": "Create Necromancer dashboard post",
"location": "subreddit", "forUserType": "moderator",
"endpoint": "/internal/menu/create-dashboard" }]
},
"permissions": {
"reddit": { "enable": true },
"redis": true,
"http": { "enable": true,
"domains": ["archive.org", "link-check.you.workers.dev"] }
}
}
Those endpoints aren't public URLs; they're routes on your app's own Express server that only the platform invokes. The cron entry means Reddit wakes your bot every 30 minutes with zero infrastructure on your side.
The Scan Pass: an Endpoint, not a Loop
Serverless reshapes the bot's core. Instead of a long-running loop, each cron
tick runs one bounded pass (a few posts, a capped number of URL checks)
and leaves a cursor in Redis so the next tick picks up where this one stopped.
The Reddit API and Redis both come in from @devvit/web/server, with
no auth code anywhere:
import express from 'express'; import { createServer, getServerPort, context, reddit, redis } from '@devvit/web/server'; const app = express(); app.use(express.json()); app.post('/internal/scheduler/scan', async (_req, res) => { const cursor = parseInt(await redis.get('cursor') ?? '0') || 0; const posts = await reddit.getTopPosts({ subredditName: context.subredditName, timeframe: 'year', limit: cursor + 3, // walk 3 posts per tick via the cursor }).all(); for (const post of posts.slice(cursor)) { await scanText(post.id, post.body); const comments = await reddit.getComments({ postId: post.id, limit: 100 }).all(); for (const c of comments) if ((c.score ?? 0) >= 3) await scanText(c.id, c.body); } await redis.set('cursor', String(cursor + 3)); res.json({ status: 'ok' }); }); createServer(app).listen(getServerPort());
Every action (check, ok, dead,
snapshot, reply) also appends an event to a capped list
in Redis. That single habit is what makes the live dashboard in Step 6 nearly
free.
The Fetch Allowlist, and the Honest Workaround
Here's the constraint that shapes this bot most: Devvit's fetch only
reaches domains declared in permissions.http.domains. That's a sane
sandbox for a platform running third-party code, but a dead-link checker's entire
job is fetching arbitrary URLs. You cannot allowlist the whole internet.
The pattern: a tiny proxy you control, on a domain you can allowlist. The app asks the proxy "is this URL dead?"; the proxy does the arbitrary fetch and answers with a verdict. Ours is about 60 lines on Cloudflare Workers:
// worker/link-check-proxy.js · GET /check?url=<target> const resp = await fetch(target, { redirect: 'follow', signal: AbortSignal.timeout(10_000) }); if (resp.status === 404 || resp.status === 410) return Response.json({ dead: true, reason: `HTTP ${resp.status}` }); // 200 + "this domain is for sale" boilerplate = parked domain = dead // timeout = NOT dead. Slow sites recover; wrong bot replies don't.
The death rules stay deliberately conservative: 404/410,
DNS failure, persistent 5xx, parked-domain text. Watch the
CHECK → OK pairs in the console above. Most links pass, and the bot's
credibility rests on the flagged ones actually being dead.
Resurrect, then Reply Like a Good Citizen
For each confirmed corpse, the Internet Archive's free availability API returns
the closest snapshot. archive.org goes in the allowlist and the app
calls it directly. Then the reply, where bottiquette lives:
🪦 **Dead link detected** (HTTP 404): - ~~blog.oldframework.io/setup-guide~~ → [archived copy from 2019-06-14](...) --- ^(I'm a bot that resurrects dead links via the Wayback Machine. Reply STOP to opt out of replies on your comments.)
- Reply once, ever. A Redis dedupe map keyed on
thingId:url. - Identify yourself and link the source.
- Offer an exit, and honor STOP replies.
- Dry-run first. The app ships with
DRY_RUN = true, logging what it would post. Read the feed for a few days before flipping it.
Devvit softens the old approval dance: mods installing your app is consent. But dry-run discipline is still what keeps them from uninstalling it a day later.
The Dashboard Is a Reddit Post
This is Devvit's best trick. The app declares a post entrypoint (a
plain HTML webview) and a mod-menu item that creates it. The webview is served by
the same app that runs the bot, so it polls /api/events
same-origin: no CORS, no keys, no third-party hosting. Pin the post and
the subreddit has a public, genuinely real-time window into what the bot is doing.
// server: the feed endpoint the webview polls every 5s app.get('/api/events', async (req, res) => { const since = parseInt(String(req.query.since ?? '0')) || 0; const events = JSON.parse(await redis.get('events') ?? '[]'); res.json({ events: events.filter(e => e.ts > since) }); }); // mod menu: create the dashboard post app.post('/internal/menu/create-dashboard', async (_req, res) => { const post = await reddit.submitCustomPost({ subredditName: context.subredditName, title: '🪦 Link Necromancer · live console', }); res.json({ navigateTo: `https://reddit.com${post.permalink}` }); });
Ship It: Playtest, Install, CI/CD
Devvit's development loop is unusually pleasant: devvit playtest
r/YourTestSub hot-reloads the app on a test subreddit while streaming logs
to your terminal. When it behaves, devvit upload publishes a version
and mods install it from the app directory. The scheduler starts on install.
One mental shift for CI: with a hosted platform, GitHub Actions doesn't run the bot; Reddit does. The workflow's job is deployment: typecheck, build, upload a new version on every push.
on: push: { branches: [main] } steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 - run: npm ci && npm run typecheck && npm run build - run: | # devvit login locally once, mkdir -p ~/.devvit # store the token as a secret printf '%s' "$DEVVIT_TOKEN" > ~/.devvit/token - run: npx devvit upload
Scheduled workflows in CI run in UTC, and the deploy pattern here mirrors the one in the GitHub Actions guide.
The Whole Recipe
Declare everything in devvit.json, let Reddit's scheduler and Redis
replace your infrastructure, route the one thing the sandbox can't do through a
proxy you control, keep the death checks conservative and the replies polite, and
serve the dashboard as a post so the community can watch the bot work. The
pattern generalizes: swap the dead-link core for any per-comment analysis, and
Devvit handles the running, the state, and the trust story.