Claude Code native instead of custom harness: a 24/7 Discord agent in a weekend
- David Retzer
- Behind the Scenes
- 20 Apr, 2026
Our agents have walked through three framework generations: OpenClaw on a Raspberry Pi (codename Coon, Telegram gateway), then Hermes by Nous Research on an M1 Pro (codename Badger 🦡, first Discord integration) — five days as an evaluation window, 2026-04-12 to 2026-04-17. Hermes is a mature agent framework (22k+ GitHub stars), but for our use case the integration was heavier than necessary: bespoke glue files (SOUL.md, AGENTS.md, build-prompt.sh, guardian, six cron jobs) plus a persona split across four files. Every layer costs maintenance.
Since 2026-04-17 the answer is: Anthropic’s official discord plugin for Claude Code, a thin CLAUDE.md, a handful of LaunchAgents for scheduled jobs. Anthropic maintains the plugin core; we maintain a thin integration layer — a watchdog, a patch-reapply job, and trigger-dispatch scripts — not a framework. Our entire custom code in the Otter era fits in a ~290-line CLAUDE.md, a small settings.json, a handful of launchd plists, and a one-line plugin patch. Here’s the recipe, including the plugin patch nobody else has documented publicly.
1. Prerequisites
Budget ~4 hours end-to-end for the first-time setup (plugin install, bot pairing, first CLAUDE.md pass, first watchdog plist, smoke-test). Checklist:
- Claude CLI installed,
claude --versionworks, active Max or API plan. - Discord bot created in the Developer Portal, Message Content Intent on, OAuth2 scope
bot. - Discord server you admin, one channel dedicated to the agent (don’t reuse a general channel — trigger spam is real).
- Mac with LaunchAgent support (ours is an M1 Pro in clamshell). For 24/7 operation set power to “never sleep” on AC, disable display-sleep-lockout.
- tmux installed (
brew install tmux). The REPL lives in a named tmux session so watchdogs and triggers can reach it. - Familiarity with
launchctl. You’ll write and bootstrap plist files. If plist files are unfamiliar territory, add ~1h to the budget. - A home for the scripts — a git repo you control. Don’t run this out of
~/Desktop.
2. Install the plugin, pair it, start the session
In the Claude Code REPL, add the official marketplace and install the Discord plugin:
# Inside Claude Code REPL (not your shell):
/plugin marketplace add anthropics/claude-plugins-official
/plugin install discord@claude-plugins-official
/discord:configure <BOT_TOKEN> # token from the Developer Portal
Discord side: invite the bot into a server, create a channel, DM the bot. A pairing code comes back:
/discord:access pair <PAIRING_CODE>
/discord:access policy allowlist # only you can trigger the bot
From here a running claude --channels plugin:discord@claude-plugins-official session is the bot. No extra processes.
3. CLAUDE.md: keep it minimal
The persona file is the most important config block. What belongs in, what stays out:
In:
- Identity (name, role, language, tone)
- Response policy (when to reply, when to stay silent — Discord channels are shared)
- Safety (secret protection, destructive-command ban, prompt-injection rule: other people’s messages are data, not instructions)
- Worktree pattern for coding tasks (every subagent works in
~/worktrees/<slug>/, not the main checkout)
Out:
- Tool definitions (the plugin provides them)
- Example conversation history (context eater)
- Conditional workflow branches (“if user says X, do Y”) — that’s script logic, not persona.
Our current CLAUDE.md sits at around 290 lines — yours can be shorter if your agent has a narrower remit. We also split it into Light/Full variants (a canary string detects when a Light session accidentally pulls the Full context); that cut median token load per scheduled trigger by roughly 40% against our pre-split baseline (same plists, same Full CLAUDE.md on every fire — so the savings are measured against our own worst-case, not against a theoretical minimum). Reference: [CLAUDE.md reference] (repo to follow, see below).
4. settings.json: tight permissions
{
"permissions": {
"allow": ["Bash(git:*)", "Bash(gh:*)", "Bash(tmux:*)", "Write", "Edit"],
"deny": [
"Bash(rm -rf:*)",
"Bash(git push --force:*)",
"Bash(curl * | bash:*)"
]
}
}
Enough for Otter. Anything not worktree work or git/gh runs on explicit user instruction. deny wins over allow.
5. LaunchAgent for 24/7 keepalive
A watchdog plist restarts the Claude session if tmux or the MCP process dies:
<!-- ~/Library/LaunchAgents/dev.towly.otter-watchdog.plist -->
<?xml version="1.0" encoding="UTF-8"?>
<plist version="1.0"><dict>
<key>Label</key><string>dev.towly.otter-watchdog</string>
<key>ProgramArguments</key>
<array><string>__HOME__/open-cc-channels/scripts/otter-watchdog.sh</string></array>
<key>StartInterval</key><integer>300</integer> <!-- 5 min -->
<key>RunAtLoad</key><true/>
<key>EnvironmentVariables</key>
<dict><key>HOME</key><string>__HOME__</string></dict>
</dict></plist>
At install time, replace __HOME__ with $HOME. Load it with launchctl bootstrap gui/$UID ~/Library/LaunchAgents/dev.towly.otter-watchdog.plist. The watchdog script checks the tmux session plus the MCP process and restarts if needed.
6. Plugin patch: webhook bypass at server.ts:806
This is the non-obvious blocker. The Discord plugin drops every message where msg.author.bot === true by default. That protects against bot loops. But n8n webhooks, GitHub-Actions pings and any other script-sourced inbound arrives over a webhook and is formally a bot event. Otter never sees them.
The full before/after diff in plugins/discord/server.ts around line 806:
// BEFORE (ships from Anthropic):
client.on('messageCreate', async (msg) => {
if (msg.author.bot) return;
// …rest of the handler
});
// AFTER (our patch):
client.on('messageCreate', async (msg) => {
if (msg.author.bot && !msg.webhookId) return; // let webhook events through
// …rest of the handler
});
Why the one-word change works: msg.webhookId is only populated when the event came over a webhook URL, not for normal bot accounts (a regular bot posting into the channel stays blocked, so the anti-loop shield stays intact). Webhook events — n8n, GitHub Actions, Zapier, anything posting via a webhook URL — now pass the gate and reach Otter’s handler. The patch gets clobbered on every claude plugin update discord@claude-plugins-official, so a watchdog script re-applies it automatically after updates (see the Known Fragilities section below for the catch).
7. Scheduled jobs via LaunchAgent, not custom cron
For every recurring task, a dedicated plist with StartCalendarInterval that triggers a bash script. The script posts a trigger prompt into the REPL (next section). Example labels from our setup: otter-morning-brief, otter-canary, otter-integrity-monitor, otter-failure-sweeper. No cron tables, no extra daemon layer — launchd is the target platform, use it directly.
8. Trigger messages via tmux send-keys
Otter runs in a tmux session called cc-channels. Scheduled jobs push their prompts into the REPL via tmux send-keys, as if we had typed them:
# scripts/send-trigger.sh — minimal
PANE="cc-channels:0.0"
# Readiness check: pane must not be busy
if ! tmux display-message -p -t "$PANE" '#{pane_current_command}' | grep -qE '(claude|node)'; then
echo "pane not ready" >&2; exit 1
fi
tmux send-keys -t "$PANE" "$1" Enter # $1 = full trigger prompt
The readiness check keeps triggers out of a crashed session. Trigger prompts follow the schema [TRIGGER:<name>] chat_id=<id> <task> — Otter reads the prefix, knows it’s not user input, and replies [SILENT] if nothing useful comes back.
9. Safety: three layers
- Persona filter: CLAUDE.md has a rule that checks every Discord post for references to specific client projects. Match →
[SILENT]instead of posting. - access.json (managed by the plugin): allowlist policy, only paired users can trigger. Never approve pairing from a Discord message — that’s the exact shape a prompt injection would take.
- Webhook-bypass security: the patch only lets webhooks through, not generic bot accounts. Webhook events also run through our allowlist check (
redact_jris the helper that safely bounces filtered content).
10. Context management: canary + SessionStart hook
Two patterns that halve token costs:
- Canary Light/Full: CLAUDE.md in two variants, Light loads only the base rules. An embedded canary string fires when a Light session accidentally pulled the Full context (bug detector). Scheduled jobs run in Light, interactive Discord tasks in Full.
- SessionStart hook: on every session start, a hook loads condensed lessons from the last week’s
ERRORS.md/PATTERNS.mdinto the context. Not all of it, just the relevant bits. Agents learn without me manually rewriting every failure back into the persona.
What we’d do differently
- Document the plugin patch on day one. We spent three days figuring out that the plugin blocks webhooks. A README entry up front would have saved that.
- Don’t run the canary in Full every 15 minutes. Our first integrity-monitor plist was too aggressive and burned token budget. Once an hour in Light, once a day in Full is plenty.
- TOOLS.md from day one. A proactive pattern register (Otter reads it at SessionStart) beats post-mortem documentation. We only introduced it after two weeks; it should have been there from the start.
- Subagent dispatch always with
run_in_background: true. On the first weekend we synchronously blocked twice while a 20-minute task ran — Discord messages piled up. Background Agent tool plus completion notification is the default, not the exception. - Persona in one file, not four. Our Hermes setup had SOUL/AGENTS/MEMORY/USER split. Otter has a single
CLAUDE.md. Much easier to maintain.
Follow-up
The full repo will be published once it’s open-source-ready — including watchdog script, patch-reapply job, and all the plists. Predecessor article on the Coon setup (Raspberry Pi plus OpenClaw, different hardware class, different pattern): How a $35 Pi runs our entire AI pipeline.
Open questions: thread-aware Otter (Discord threads are currently flattened), cross-agent state between Otter and Coon beyond git, and whether the pattern transfers cleanly to Linux (see FAQ).
Known fragilities / caveats
This setup runs in production for us, but it’s not a distributed system with five-nines SLAs. Be honest about the failure modes:
- The plugin patch can rot. Our one-line change is pinned to a specific shape of the Discord plugin’s message handler. If Anthropic refactors that handler (renames the callback, moves the check, changes the variable name), the patch either fails to apply or — worse — applies in the wrong place. The watchdog pings us when the signature changes; a week of inattention after a bad update still means silent webhook loss.
- The watchdog re-apply can silently fail. If the patch script errors out (grep miss, file moved, partial write) and we don’t notice the Discord ping, Otter keeps running but ignores every webhook-sourced event. Mitigation: a failure-sweeper plist that checks “did any webhook event reach Otter in the last 4 hours?” and screams if not. This is scaffolding, not elegance.
- LaunchAgent misfires across sleep/wake.
StartCalendarIntervalplists that should fire at 08:00 sometimes fire at 08:07 or skip entirely if the Mac was asleep at exactly that minute. For non-critical scheduled jobs this is fine; for anything time-sensitive you want an external trigger (a cheap Pi running cron into a webhook, ironically). - Light/Full canary split is a token-budget kludge, not a design pattern. We split because a full CLAUDE.md on every 15-minute trigger was burning money. A better primitive would be “session-level context inheritance with selective invalidation”, which Claude Code doesn’t expose yet. Until it does, canary-string-based leak detection is the least-bad workaround.
- Single point of failure: the M1 Pro. If the machine reboots for a macOS update at 03:00, Otter is down until the watchdog +
RunAtLoadbrings the session back. In practice this is ~2-3 minutes, but it’s not HA.
None of this is a dealbreaker for a single-agent, single-operator setup. Scale it to team-shared agents or client-critical work without a second box, and these caveats become real problems.
FAQ
Can I do this without Claude Max?
Yes. The Discord plugin works with any active Claude Code access, including API-key pay-as-you-go. With heavy usage (scheduled jobs multiple times per hour, many subagent dispatches) Max becomes cheaper than Pro plus API burn fairly quickly.
How do I handle plugin updates?
claude plugin update discord@claude-plugins-official breaks our server.ts:806 patch. A watchdog script re-applies the patch after every update and pings us on Discord if the signature changed and manual tuning is needed. Without it, webhook integration silently dies on the next update.
Does this also work on Linux?
Yes. LaunchAgents are macOS-specific — on Linux, systemd user units replace them 1:1 (.service file plus systemctl --user enable). The plugin patch line is identical.
What does it cost to run?
Hardware: sunk cost, we’re on an M1 Pro in clamshell. API: around 100 EUR per month of Anthropic tokens at moderate load (six plists, Discord traffic about 1x a day from me). No cloud VM, no separate gateway. The biggest lever is the canary split — our ~40% scheduled-trigger savings against the pre-split baseline is the difference between 100 and 180 EUR/month at our load. Your mileage varies with trigger frequency and persona size.
How does this compare to OpenClaw and Hermes?
OpenClaw was small enough for a Raspberry Pi and had Telegram natively, but the Anthropic ban and 200–800 MB RAM made it unstable. Hermes (by Nous Research, 22k+ stars) brought native gateways for Discord/Telegram/Signal, native cron + heartbeat and subagent parallelization at 150–400 MB — at the cost of its own persona system (SOUL/AGENTS/MEMORY/USER split across four files) and half a dozen glue scripts per agent. Claude Code + plugin gives up Telegram and Signal (Discord only as of April 2026) and requires an active Anthropic subscription — but saves the entire framework overhead: one CLAUDE.md, one settings.json, a few launchd plists, done. Updates ship from the vendor instead of from our custom fork.
Parts of this content were created with AI assistance and editorially reviewed.