Combat sycophancy. An LLM in a code editor can become an echo chamber — the model agrees with your half-baked ideas, reinforces your assumptions, and never pushes back. Gadfly fixes this by spawning a devil's advocate into a second Claude Code session that reads every response you get and argues against it in good faith.
Structurally: a Claude Code channel that forwards every assistant turn from one Claude Code session into a second Claude Code session, where it gets read by a configurable persona — by default a devil's advocate — that pushes back on it.
No API keys, no separate model, no special launch incantations: the critic is just another Claude Code instance, and role assignment is decided by the order the two sessions start up.
┌──────────────────────────┐ ┌──────────────────────────┐
│ Claude Code "subject" │ │ Claude Code "critic" │
│ (first to start) │ │ (started later) │
│ │ │ │
│ Stop hook ─POST /publish─┼──▶ gadfly (subject) ──▶│──▶ gadfly (critic) ──▶ │
│ │ subject.json │ <channel> tag into │
│ │ ephemeral port │ critic's context │
│ │ SSE /events ────────┘ │
└──────────────────────────┘ └──────────────────────────┘
Both sessions launch Claude Code identically (just claude from this
directory). Each one's gadfly.ts subprocess races for a PID-file lock at
~/.cache/gadfly/subject.lock:
- Winner = subject. Starts a localhost HTTP server on an ephemeral port,
writes
{claude_pid, gadfly_pid, port}to~/.cache/gadfly/subject.json, and broadcasts POSTed messages to all SSE subscribers. It does not push events into its own MCP session. - Loser = critic. Reads
subject.json, opens a long-lived SSE connection to the subject's/events, and forwards each message into its own MCP session as anotifications/claude/channelevent. Exits when the SSE stream closes.
The Stop hook walks its own process tree to find its claude ancestor and
only POSTs if that PID matches subject.json's claude_pid. Critic sessions
have the same hook installed but their claude PID won't match, so the hook
no-ops and the self-critique loop is broken at the source. No env vars, no
opt-in flags, no separate .mcp.json files.
If the subject session exits, the critic's SSE connection closes and its
gadfly subprocess exits cleanly — you'll see it disappear from /mcp.
Restart whichever session you want to be the new subject; whichever gadfly
wins the next lock race takes over.
- Bun
jq(used by the Stop hook)- Claude Code v2.1.80 or later, signed in with claude.ai (channels do not work with API key auth)
- On Team / Enterprise plans, an admin must enable channels
Clone or download gadfly to a directory on your machine. Then install dependencies:
bun installRegister the Stop hook globally using claude mcp add:
claude mcp add gadfly -- bun /path/to/gadfly/gadfly.tsThen add the Stop hook to your user-level ~/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "/path/to/gadfly/hooks/stop-to-gadfly.sh",
"timeout": 5
}
]
}
]
}
}Now, open exactly two Claude Code windows on the same computer:
Terminal 1 — the subject (work in any project):
cd ~/my-project
claudeTerminal 2 — the critic:
claude --dangerously-load-development-channels server:gadfly(The --dangerously-load-development-channels flag is required because custom channels aren't on the Anthropic allowlist during the research preview.)
Whichever starts first becomes the subject (being critiqued). The second becomes the critic (pushing back).
Important: gadfly uses a single lock file and ephemeral port on each machine, so you can only run one subject/critic pair at a time. If you need to switch which project is being critiqued, kill the subject window and start a new one in the desired project.
The critic's persona is set via the instructions field on the MCP server,
which Claude Code adds to the system prompt of the critic session. You can
swap it without touching code.
Loader priority (first one wins):
GADFLY_PERSONALITYenvironment variablepersonalityFilefield ingadfly.config.json(path to a text file)personalityfield ingadfly.config.json(inline string)- Built-in default
A fixed preamble explaining the channel mechanics is always prepended, so
you can write a personality file that's purely about tone and focus —
nothing about <channel> tags or "don't call tools" needs to be in your
persona text.
Edit gadfly.config.json in the gadfly installation directory:
{
"personalityFile": "./personalities/pedant.md"
}Then restart the critic (Terminal 2). The new persona takes effect on the next message.
| File | Tone |
|---|---|
personalities/devils-advocate.md |
Pushback in good faith, strongest counter-argument |
personalities/pedant.md |
Three most likely production failure modes |
personalities/optimist-check.md |
Sympathetic but rigorous; one biggest risk only |
Drop a .md (or .txt) file into personalities/, point personalityFile
at it, restart the critic. The file is treated as raw text — no frontmatter,
no templating. Keep it focused on tone, format, and what to look for; the
preamble already handles the plumbing.
gadfly writes a couple of files to ~/.cache/gadfly/:
| File | Purpose |
|---|---|
subject.lock |
PID file — existence + liveness decides subject-vs-critic |
subject.json |
{claude_pid, gadfly_pid, port} for the current subject |
Both are cleaned up on clean exit (SIGTERM / SIGINT / normal shutdown). If
gadfly gets kill -9'd the files are left behind; the next startup detects
the stale PID via kill(pid, 0) and takes over automatically.
/mcp shows gadfly as "failed to connect" — check
~/.claude/debug/<session-id>.txt for the stderr trace. The most common
cause is that a stale subject.lock from a previous kill -9 is pointing
at a PID that was recycled by the OS to another process, so the liveness
check succeeds but the port in subject.json goes nowhere. Nuke
~/.cache/gadfly/ and restart both sessions.
Both windows think they're the critic — means neither acquired the lock
cleanly. Usually caused by a leftover gadfly process you didn't kill. Check
pgrep -fl "bun.*gadfly" and kill stragglers.
Subject window is sending but critic doesn't react — confirm the critic
still has gadfly attached (/mcp in the critic), then cat ~/.cache/gadfly/subject.json and curl -N http://127.0.0.1:<port>/events
in a spare terminal to watch the SSE stream directly. If curl sees events
but the critic doesn't react, the critic's gadfly subprocess has likely
died — restart the critic window.
Stop hook never fires — check the matcher + hooks wrapper shape in
your settings.json (Claude Code will reject the whole file silently if
the shape is wrong) and make sure the script is executable
(chmod +x hooks/stop-to-gadfly.sh).
- gadfly binds to
127.0.0.1only. It is not reachable from off-host. - It is unauthenticated. Anything local that can write to the subject's ephemeral port can put text in front of the critic. Don't run gadfly on a shared machine without adding sender gating (see the channels reference).
- Critiques stay in the critic's terminal. Nothing is sent back to the subject. (Two-way relay is a possible future extension.)