AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
A new interface to building — run coding agents over Discord/Slack
Translation pending
Developer toolsTypeScript · MermaidGitHub @sf8193Self-installOwner unconfirmed
Paste into Claude or ChatGPT for installation instructions suited to your environment.
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
Owners of public repositories under a personal GitHub account can sign in to verify ownership and manage the introduction themselves. No administrator approval is needed.
Sign in, generate a verification file, and commit it to this repository’s default branch within 30 minutes. Files on other branches or forks do not count. Then check the file below to manage the project without administrator approval. Keep the file to retain your access.
If needed, you will go to sign-in. After signing in, return here and press your chosen verification button again.
Installable projects can be verified without a deployment domain. Use GitHub sign-in for a personal repository, or the repository verification file above for an organization repository.
Ask about other verification options
Loading the full README. The saved excerpt is shown below.
A new interface to building — run coding agents over Discord/Slack.
• Spawn and manage parallel agents from chat
• tmux in to manage from terminal
Key design decisions:
• One gateway connection per platform. Prevents token race conditions (Discord) and simplifies state.
• Daemon ↔ Bridge separation. The daemon is long-lived; Claude sessions come and go. The bridge reconnects automatically.
• Platform selection via env var. Set CHAT_PLATFORM=discord or CHAT_PLATFORM=slack. Default: discord.
• Simultaneous platforms. Run two daemons on different state dirs for Discord + Slack at the same time.Read the full README ↗A new interface to building — run coding agents over Discord/Slack.
┌─────────────┐ ┌─────────────┐
│ Discord │ │ Slack │
│ Gateway │ │ Gateway │
│ (discord.js)│ │(@slack/bolt)│
└──────┬──────┘ └──────┬──────┘
│ │
└────────┬──────────┘
│
┌────────▼────────┐
│ Daemon │ Single process per platform.
│ (daemon.ts) │ Holds gateway connection,
│ │ routes messages, manages
│ unix socket │ sessions and access control.
└────────┬────────┘
│ newline-delimited JSON
┌────────▼────────┐
│ Bridge │ Thin MCP relay. One per
│ (bridge.ts) │ Claude session. Platform-
│ │ agnostic — doesn't import
│ stdio ↔ socket │ any chat SDK.
└────────┬────────┘
│ MCP (stdio)
┌────────▼────────┐
│ Claude Code │ Full Claude with tools,
│ │ memory, file access, etc.
└─────────────────┘
Key design decisions:
CHAT_PLATFORM=discord or CHAT_PLATFORM=slack. Default: discord.curl -fsSL https://bun.sh/install | bashbrew install tmuxnpm install -g @anthropic-ai/claude-code# Install dependencies
bun install
# Create .env with your bot token
mkdir -p ~/.claude/channels/discord
cat > ~/.claude/channels/discord/.env << 'EOF'
DISCORD_BOT_TOKEN=your-token-here
EOF
# Install watchdog + verify setup
bun cli/hydra.ts install discord --cwd ~/your/project
# Start
bun cli/hydra.ts up discord
All operations go through the hydra CLI (bun cli/hydra.ts or alias to hydra).
hydra install <platform> # Generate launchd watchdog, run preflight
hydra uninstall <platform> # Remove launchd watchdog
hydra preflight <platform> # Verify deployment is ready
hydra up <platform> # Start daemon + byte
hydra down <platform> # Stop byte + daemon
hydra restart <platform> # Restart daemon (picks up code changes)
hydra spawn <prompt> # Spawn a new session
hydra list # List active sessions
hydra status <name> # Session details
hydra kill <name> # Kill a session
hydra peek [name] # View live sessions (chooser or direct attach)
hydra health # Daemon diagnostics
hydra clear-key <key> # Clear a stuck idempotency key
--daemon <name> Target a specific daemon (when multiple running)
--json Output raw JSON
Set in ~/.claude/channels/<platform>/.env:
# Discord
DISCORD_BOT_TOKEN=MTIz...
# Slack
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
Reports session lifecycle metadata to Raindrop — spawn, reply, death, and 👍/👎 reactions as sentiment signals. No message text is ever sent.
Events go to /v1/events/track, the vendor's plain-event endpoint, as a
one-element array. Not track_partial: hydra sends no prompt or completion
text, and a finalized partial carrying neither renders as an empty
AI-generation row — the vendor's own Rust and Python SDKs drop that shape
client-side. The price is that the plain endpoint's ingest schema has no
conversation field, so the thread id travels as an ordinary property and
grouping is a filter rather than a built-in conversation view.
Two wire bodies, both in daemon/raindrop-payload.ts. An event carries the
event name, your user_id, an ISO timestamp, a platform message or session id
as event_id, and properties: the SESSION_PROPERTY_KEYS allowlist
(tmuxName, engine, sessionType, originType, platform) plus repo,
threadId, model and replyChars. A signal carries an event_id, a fixed
signal name, a sentiment, and the constant signal_type. Nothing else leaves
the machine.
Every property value is shape-checked at the boundary, not trusted from
upstream: 64 identifier characters, and anything else is dropped rather than
sent. Three fields carry a value the calling session or its spawner chose.
model is resolved against the model catalogue and dropped if it isn't one.
repo is the git project the session's worktree belongs to, capped at 40
identifier characters — which bounds it but does not close it, since a session
that can create directories under SPAWN_CWD chooses up to 40 of those bytes.
Set RAINDROP_OMIT_REPO=1 and the field is never sent; any non-empty value
omits, so the control fails closed. threadId is a spawned session's own
thread and, for main, the reply's chat id; it is bounded to 32 identifier
characters plus an optional Slack timestamp rather than closed.
Set in the state-dir .env alongside the bot tokens. The daemon reads that
file itself and then removes the four RAINDROP_ vars from its own
environment, so they are in no command line, no ps output, and nothing the
daemon forks — a child started with the daemon's environment would otherwise
carry the key where any process running as you can read it. That is not a
secret from a session that goes looking: the file is 0600 and every session
runs as you, exactly as it already does for the bot tokens.
The spawn boundary scrubs as well, because the CLI reaches tmuxNewSession()
on paths that never load the daemon's config. Creating a tmux
session goes through tmuxNewSession() in shared/spawn-env.ts and nothing
else may; spawning codex goes through codexSpawnEnv(). Both are enforced by
test. A new window in an existing session is not covered and does not need to
be — a window's pane inherits the server environment, which the sweep has
already cleared. tmuxNewSession() does two things — it sanitizes the environment handed
to the child, and it sweeps an already-frozen server with set-environment -gr
— because a pane inherits the tmux server's environment rather than the
client's. codexSpawnEnv() only sanitizes; codex never starts a tmux server,
and a pane it lands in was already swept. On the shell path
scrub-raindrop.sh does both halves, sourced by env-setup.sh and by
start-transcribe.sh. That also means a value
frozen there would outrank both .env files. Only the daemon re-reads them,
from the file. Starting the daemon clears them from a server that already holds
a copy.
Three rungs, highest first: the daemon's inherited environment, the repo-root
.env, the state-dir .env — each only fills what the one above left blank.
So a stray RAINDROP_ line at the repo root overrides the state dir, including
an explicit off. The top rung is not a way in: exporting a RAINDROP_ var in
your shell does not work, because the CLI and env-setup.sh both strip them
before the daemon starts — RAINDROP_MODE=dryrun hydra restart is a silent
no-op. It has to go in a file.
# dryrun writes payloads to <state-dir>/raindrop-dryrun.jsonl and sends nothing.
# Over 5MB the oldest lines drop back to a 2MB tail, checked once a minute —
# sample that file, don't archive it.
# Switch to `live` once you've read it and are happy with it.
RAINDROP_MODE=dryrun
# Printable ASCII only. A key that picked up a zero-width space or smart quote
# on the way out of the browser is refused, and hydra health names it. Quote the
# value: env-setup.sh shell-sources this file, and a bare quote inside it aborts
# that source — under watchdog.sh, silently, dropping every var below this line.
RAINDROP_WRITE_KEY="..."
# Any non-empty value omits the field — a misread value withholds data rather
# than sending it. Blank or absent leaves repo names on. hydra health names a
# value that isn't the literal 1.
# RAINDROP_OMIT_REPO=1
# Your own platform user id (Slack U…/Discord snowflake), stamped on EVERY
# session's events. Required once more than one person can drive a session — a
# second allowFrom entry, or any channel group (an empty group allowFrom admits
# every member); until then the module refuses to guess and sends nothing.
# Leave blank rather than guessing: a wrong id attributes every event to a
# stranger, silently.
RAINDROP_USER_ID=
Unset RAINDROP_MODE means no listeners are registered at all. live without a
write key is treated as off and logged, never as a silent dry-run. Changes need a
daemon restart.
hydra health (CLI and chat) reports the current mode, a running count of
events and signals recorded with how long ago the last one was, whether events are
attributable, whether RAINDROP_USER_ID names someone who can actually drive a
session, whether RAINDROP_OMIT_REPO was understood, and any failures. That count is the check after switching to
live: send one message to a session, re-run hydra health, and confirm it
went up. Before any traffic a healthy install and a bad key look identical — a
401 only shows once a send has been attempted. A rising count proves the vendor
accepted the POST, not that it landed where you meant: open Raindrop and
confirm the hydra.session.* events and thumbs signals appear under the user
id you configured. Note the write key is org-scoped and hydra sends no
X-Raindrop-Project-Id, so everything lands in the org's Production
project regardless of which project page you copied the key from. The counters reset on restart. The dry-run file is created lazily
— it won't exist until the first session event — so on a fresh enable, check
hydra health rather than ls — grep raindrop ~/hydra-<platform>-daemon.log only confirms registration and names failures,
since nothing is logged per event. It is also append-only across restarts, so an old file is not evidence the
current daemon is writing; check its mtime.
Switching back to dryrun stops egress but is not a teardown — the key stays
live on disk, and health will not mention it because a key is expected during a
dry run.
Turning it off stops the reporting, not the whole change: the tmux environment
sweep, scrub-raindrop.sh, the capture that strips the four vars from the
daemon's own environment, the reaction event, the Discord reaction intent and
the dry-run file's 5MB trim are all unconditional. If the problem is in one of those, this is a revert
and a restart, not an .env edit.
To turn it off: remove RAINDROP_MODE and RAINDROP_WRITE_KEY from both
the state-dir .env and the repo-root one, then restart and confirm hydra health no longer prints a Raindrop line (raindrop: on the CLI,
• Raindrop: in chat). Both files, because either rung alone re-enables it. A
key left behind is inert but still a live credential on disk.
Delete <state-dir>/raindrop-dryrun.jsonl yourself; re-enabling appends to the
existing file. The 5MB trim keeps running from the vitals tick whatever the mode
is, so a dry-run file kept as evidence still loses its oldest lines — copy it
elsewhere if you need it intact. Anything already sent in live mode
is at the vendor: deleting it there is your job, not hydra's.
Known limits:
hydra.session.death fires on every killSession path — kill, respawn, reap,
protocol completion, thread destroy. A session that crashes emits nothing
at the moment it dies; it only reports when something reclaims the thread.
Reclaiming in the same daemon does emit, stamped with the crash time rather
than the reclaim, so duration stays honest. Reclaiming after a daemon restart
emits nothing at all — the new process never knew the session — so deaths are
under-counted by the crashed-then-restarted population.hydra.session.spawn for sessions already alive at that
moment — they are re-seeded as known. On the very first enable that is every
session running at the time, so their deaths arrive with no matching spawn. A
spawn-vs-death funnel will therefore under-count spawns across restarts.reaction_added in its bot events (docs/slack.md), and reacting in a Discord
DM needs the DirectMessageReactions intent. Slack needs the manifest updated
and the app reinstalled; the Discord intent is code-side and non-privileged, so
a hydra restart on this version is enough.model is the model the session launched with, for claude sessions: an
in-session /model switch does not change it, and for main a byte restart
surfaces within 10 minutes. Codex sessions differ — the app-server reports the
thread's current model on every daemon boot, so a switch does surface. A model
outside the catalogue is dropped rather than sent, which reads the same as a
session that had none.dryrun, or a vendor outage, needs a daemon restart to take
effect — the mode is read once at boot. There is no runtime off switch, and a
live install with an unreachable vendor keeps trying one 5s request per
event, logging each failure, until you restart it.reaction_removed is not subscribed; repeating the same
reaction is de-duplicated, changing your mind is not.repo is present only for a session spawned into a worktree. A plain
spawn: session runs in SPAWN_CWD and reports none, so a repo-grouped view
covers part of the fleet rather than all of it.The intent that carries 👍/👎 also reaches the :hocho:/🔪 delete handler,
which could not fire in a Discord DM before. Intended, and gated on the
top-level allowFrom as it already was in guilds — but it is a permission
change, and turning Raindrop off does not revert it.
access.json controls who can message the bot. Lives in the state dir (~/.claude/channels/discord/ by default).
{
"dmPolicy": "pairing", // pairing | allowlist | disabled
"allowFrom": ["user-id-here"], // platform user IDs
"groups": { // channel-level policies
"channel-id": {
"requireMention": true,
"allowFrom": [],
"threadReply": true
}
},
"ackReaction": "👀",
"replyToMode": "first", // first | all | off
"textChunkLimit": 2000,
"chunkMode": "newline" // newline | length
}
See ACCESS.md for full reference.
# Install both
hydra install discord
hydra install slack
# Start both
hydra up discord
hydra up slack
Each platform gets its own daemon, state dir, and watchdog. Use different CLAUDE_CONFIG_DIR values for separate logins.
Hydra can transcribe inbound audio attachments (Discord voice notes, Slack audio clips) to text, so you can dictate prompts to Claude alongside text and images.
Transcription runs in a self-hosted sidecar (transcribe-server/) so audio never
leaves your machine. Claude doesn't accept audio natively, so the daemon
transcribes first and merges the text into the message as [voice transcript] ...;
the original audio file stays available in downloaded_files. Backend by platform:
It's on by default on the daemon side — whenever a sidecar is reachable, voice notes are transcribed; when it isn't, audio just passes through. So the only thing to set up is the sidecar.
Try it right now (no model install):
./start-transcribe.sh mock # GPU-free stub, returns a canned transcript
Send a voice note → Claude receives [voice transcript] This is a mock transcription....
(The mock is manual-only: the daemon never auto-starts it, so a leftover mock
setting can't silently feed canned text into real messages. hydra down or
tmux kill-session -t hydra-transcribe stops it.)
Real transcription (one-time; needs ffmpeg — brew install ffmpeg):
./transcribe-server/setup.sh # venv + the right backend for your platform
That's it. Once set up, the sidecar starts and stays up with the daemon —
hydra up, start-daemon.sh, and the watchdog all bring it along; hydra down
stops it. One shared tmux session (hydra-transcribe) serves every platform
daemon. Run ./start-transcribe.sh to start it by hand. Set
HYDRA_TRANSCRIBE_AUTOSTART=0 to keep the daemon from managing it, or =1 to
force autostart even before setup (loud failure instead of a quiet skip).
If the sidecar is unreachable, the daemon logs it and delivers the message without a
transcript — dictation never blocks normal messages (a down sidecar fails fast; a
live-but-slow one delays only the voice message itself, up to
HYDRA_TRANSCRIBE_TIMEOUT_MS, 60s default). Disable entirely with
HYDRA_TRANSCRIBE_ENABLED=0. Full setup, env vars, and tuning:
transcribe-server/README.md.
| Tool | Description |
|---|---|
reply | Send a message. Takes chat_id + text, optionally reply_to for threading and files for attachments (max 10, 25MB each). Auto-chunks long messages. |
react | Add emoji reaction to a message. |
edit_message | Edit a previously sent message. |
fetch_messages | Pull recent history (up to 100). |
download_attachment | Download attachments from a message to local inbox. |
create_thread | Create a thread on a message or standalone. |
spawn_session | Spawn a new Claude session for a topic (main session only). |
list_sessions | List active spawned sessions (main session only). |
kill_session | Kill a spawned session (main session only). |
Spawn isolated Claude sessions from chat:
| Command | Action |
|---|---|
spawn: <topic> | Create a new session with a thread |
kill: <name> | Kill a session by name |
/sessions | List active sessions |
listen / pause | Toggle auto-routing in a session thread |
help / commands | Show all available commands |
Sessions get cute names (spark, pixel, nova...) and run in their own tmux sessions. State persists across daemon restarts.
watch [pr-url] in a session thread watches a PR (no URL: the PR for the session's current branch). The daemon polls every 3 minutes and delivers new review feedback and CI failures to the session. unwatch <pr-url> stops; watches lists them.
Add +green (watch <pr-url> +green, or watch_pr with notify_green: true) to also post ✅ CI green in the thread when CI passes, once per head commit. It is a plain thread message, not a session turn. Using it on an already-watched PR turns it on for that watch; watches marks these with 🟢.
The daemon posts account-wide Claude rate-limit alerts to the default channel: the 5-hour window at 80% and 95%, the weekly window at 80%, 90% and 95%, once per threshold. They re-arm when the window resets, or when usage falls well below the last alert. The figures come from Claude Code itself: the hydra mod (mod/) in byte reports each change through the main-only report_usage tool. Byte's footer ("You've used N% of your weekly limit") is a fallback for the weekly window while the mod is not reporting; both feed one dedupe (daemon/usage-alerts.ts), persisted in the state dir so a restart does not re-alert.
handoff [model][: note] in a session thread asks the live session to write a handoff file and call the handoff tool. A fresh session then continues in the same thread, worktree, and label, optionally on another model.
A note goes after a colon: handoff: watch the open PRs and refine them, or handoff opus: …. A spaced dash (handoff - …, -, – or —) works too.
Note to pass on: …), and the handed off message says whether the successor got it.handed off message says so.handoff-related bug, handoff looks broken) is never a command. A line that looks like an attempt but doesn't parse (handoff -- x, or a non-model word before the colon, handoff status: x) also goes to the session as chat, and the daemon replies with the command's form.departing.md must include {{note}}, or the outgoing session never sees the note. The successor still does.Four optional files in the state dir customize what each side is told. All are re-read each time they are used, so edits apply without a restart:
actions/handoff/departing.md replaces what the outgoing session is told. It must tell the session to write the file and call the handoff tool with path= (usually {{artifact}}). Nothing enforces this, so a template that omits it never hands off.actions/handoff/arriving.md is inserted after the read-your-handoff line and replaces the built-in arrival behavior (the Reception note, the greeting's content, starting the Next action at once). Thread ids, the greeting itself, the description and the fork recipe stay.actions/handoff/notice.md replaces the message the thread sees when the handoff is requested. Useful when your departing steps take longer than the built-in ones, so the advice on when the session looks stuck should change too.actions/handoff/converge.md replaces the converge nudge (below). Its placeholders are pct (the session's context now), then (the built-in text's last sentence on when to hand off: at the full-handoff line; the next time it is idle when it is already past that line, or at the next natural break if it was already asked there; or at the next natural break when that handoff is off), full (the full-handoff line, empty when that handoff is off) and session.{{name}} placeholders are filled per handoff:
| Placeholder | departing, notice | arriving |
|---|---|---|
artifact | ✓ | ✓ |
session | ✓ (outgoing) | ✓ (successor) |
requester | ✓ | |
model | ✓ | |
cwd | ✓ | ✓ |
worktree | ✓ | ✓ |
branch | ✓ | ✓ |
label | ✓ | |
note | ✓ | ✓ |
from | ✓ | |
from_session | ✓ | |
from_transcript | ✓ |
from_session is the predecessor's Claude session id and from_transcript its transcript path. Each is empty when unknown, and always for a Codex predecessor. note is empty when the command had none. Unknown placeholders are left as written. A missing file uses the built-in text. An empty or unreadable one does too, and the daemon logs a one-time warning.
Off by default. Each is set in the state dir's .env, which the daemon reads at startup, so a change takes a restart; the 5-minute health poll notices a session crossing a line within 5 minutes. A percent is a whole number 1–99; anything else turns the converge nudge off and keeps the full handoff at 85.
AUTO_CONVERGE_PCT=<percent>: when a session's context reaches this, it is told once to start converging toward a handoff: finish or park side work, and write down what it learned where its successor will read it. It is told, not asked to hand off, so it may be mid-task, and only a session owner with a connected bridge and no protocol run is told. It is told again after its context drops below the line and climbs back, and after a daemon restart. It isn't told if it has already been asked to hand off automatically since its context reached the line. A session that starts above the line, such as a fork, is told on the first poll.AUTO_HANDOFF_FULL=1: an idle session at 85%+ context is asked to hand off, exactly as if handoff had been typed, before it is too full to answer anything. AUTO_HANDOFF_FULL_PCT=<percent> moves the line.AUTO_HANDOFF_CACHE=1: a Claude session at 50%+ context that nobody has touched for 50 minutes is asked to hand off while its prompt cache is still warm.Only session owners with a connected bridge, no protocol run and no pending permission are asked to hand off; a request unanswered after 3 minutes is repeated once. For example, AUTO_CONVERGE_PCT=50, AUTO_HANDOFF_FULL=1 and AUTO_HANDOFF_FULL_PCT=70 nudge at 50% and hand off at 70%.
hooks/on-kill in the state dir already runs on a handoff, with HYDRA_KILL_REASON='handed off'. Use it for post-handoff work nobody waits on.
hooks/pre-handoff in the state dir runs before a handoff, and can refuse it. A handoff can't be undone once it starts, so this is the place to check your own definition of done. If the file exists and is executable, the handoff tool runs it and waits:
HYDRA_* identity vars as on-kill, plus HYDRA_CWD: the session's worktree, or its working directory. The hook runs in that directory. If the directory is gone or unknown, the hook runs from the temp dir with HYDRA_CWD empty, so a git check can tell rather than judge the wrong repo.handoff refused by hooks/pre-handoff:, followed by the last 1500 characters of the hook's output.pass, refused or fail-open), the exit code, the duration, and for a fail-open the reason, e.g. daemon: pre-handoff hook flint: fail-open, exit 127, 41ms (the hook could not run a command). No hook file logs nothing; a non-executable one logs only its "skipped" warning; a gone session dir adds one line saying so before the run.For example, to refuse letters with no Next action:
#!/bin/sh
grep -q '^#* *Next action' "$1" || { echo "letter has no Next action heading"; exit 1; }
spawn_session can start from a copy of another Claude session's conversation instead of from scratch:
fork_from="<name>" forks the session that ran under that name, alive or ended — never a later session that took over its thread. fork_from="predecessor" forks the session the caller took over from by handoff. The fork launches in the directory its source launched from, on its source's model unless model is given. The source is not touched.read_only=true blocks Edit, Write and NotebookEdit. Bash stays available, so it is a guard, not a sandbox. A resume keeps it; a handoff successor starts without it.CODEX_HOME, and launching there restarts the source's app-server.To ask the session you took over from a question only it can answer, a handoff successor has ask_predecessor({ question }). It runs one turn on a fork of the predecessor's conversation and returns the answer as the tool result, without creating a session, thread or bridge. For Claude that's claude --resume <id> --fork-session -p in plan mode, limited to Read, Grep and Glob, with no MCP servers and no session persistence: the fork can't reach Hydra or change anything, and it leaves no transcript.
Each call re-reads the predecessor's whole context, so it costs one full-context turn; it gives up after 180s, and a session runs one at a time. Claude prunes old transcripts (cleanupPeriodDays), so after about a month the tool reports that the conversation is gone.
The tool appears only where it works: the predecessor's engine can fork it headless (today only Claude), and the successor's client waits long enough (today only Claude's bridge; Codex's MCP client stops at its default tool_timeout_sec of 60s, omp's bridge at a fixed 60s). A Codex or omp successor of a Claude predecessor uses the spawned fork below instead; a Codex predecessor can't be forked either way yet.
For a question that needs more than one turn, spawn a fork session instead:
spawn_session(fork_from="predecessor", headless=true, read_only=true, phase_budget="5m", topic="<question>")
The fork answers with send_to_thread(type="result") and stops. Each fork is a full session boot that loads its source's whole context, so it takes seconds and costs that context's tokens.
Symptoms first — each maps to one root cause. See docs/ONBOARDING_TIPS.md for a full first-machine checklist.
Raindrop is enabled but nothing is being recorded.
Run hydra health — after restarting the daemon, which reads its
environment at boot, so an edited .env means nothing until then. No Raindrop
line (raindrop: on the CLI, • Raindrop: in chat), or one reading
RAINDROP_MODE is off or unset, means the mode resolved to off — either unset everywhere, or an
off that outranked your dryrun. Check the repo-root .env first; it
beats the state dir. That same line after a teardown is the reminder that the
write key is still on disk.
"no allowlisted user" means nobody can be identified at all: nobody has paired
yet, or access.json was corrupt and got reset (hydra moves it aside and
starts from defaults). If it says "no attributable user", the opposite —
more than one person can
drive a session — most often a channel group with an empty allowFrom, which
admits every member — and nothing is sent until RAINDROP_USER_ID is set. That
can start happening long after boot, because access.json is re-read on every
message. Signals are narrower than events: an event is attributed to
RAINDROP_USER_ID, so only a reaction from that same id records one. Anyone
else's 👍/👎 is dropped rather than filed as yours, and hydra health does not
count it as a failure.
"N send failures" (live) or "N write errors" (dryrun) counts rejected posts,
failed local writes, and any error thrown inside a raindrop listener. Get the
detail from grep -E 'raindrop: (send|write) failed' ~/hydra-<platform>-daemon.log
— a 401 is a bad write key. If health shows a normal mode and no failures, the dry-run file is
created lazily and only appears after the first session event.
Bot is online but a spawned thread stays empty, or spawn: does nothing.
Command interception (spawn:, kill:, /sessions, /health) fires only for senders in the top-level access.json allowFrom — separate from a channel group's allowFrom. A group lets replies through; commands need you in the global allowlist. → /discord:access allow <your-snowflake>. (The daemon logs command-shaped message from non-allowlisted sender … when this happens.)
Byte or spawned session hangs on a theme picker, login, or "trust this folder" screen.
The byte is a second, headless Claude in tmux using CLAUDE_CONFIG_DIR (default ~/.claude), so it reads $CLAUDE_CONFIG_DIR/.claude.json — not ~/.claude.json. A fresh config dir triggers first-run gates that block a detached session. → Complete them once via tmux attach -t <platform>-byte, or pre-seed theme, hasCompletedOnboarding, bypassPermissionsModeAccepted, and per-project hasTrustDialogAccepted. hydra preflight now flags this.
Bot connects but never sees inbound (looks healthy, ignores everyone). → Enable Message Content Intent (Developer Portal → Bot → Privileged Gateway Intents), or the bot receives empty message content.
A spawned session runs but its bridge never connects (no plugin:discord:discord lines in its debug log).
Claude Code keeps $CLAUDE_CONFIG_DIR/mcp-needs-auth-cache.json, and while it holds an entry for a server (~15 min) every new Claude process sharing that config dir skips the server silently. The daemon removes the bridge's own entry before every launch it makes and logs cleared Claude Code needs-auth cache entry for plugin:discord:discord (set Nm ago) before launching <name>; a session that still ends up bridgeless says "Claude Code had the bridge marked needs-auth (cleared at launch)" in its thread. The byte is launched outside the daemon, so if the byte is the one without a bridge, delete that key by hand and restart it.
Byte dies instantly, or spawns fail to launch.
→ Check SPAWN_CWD points at a directory that exists. Inspect ~/hydra-<platform>-byte.log and ~/hydra-<platform>-daemon.log.
usage shows ? instead of context percentage.
Claude Code doesn't display context % in the status bar by default. Add a statusLine hook to $CLAUDE_CONFIG_DIR/settings.json:
{
"statusLine": {
"type": "command",
"command": "~/.claude/statusline.sh",
"refreshInterval": 5
}
}
Where ~/.claude/statusline.sh extracts the percentage from the JSON passed on stdin:
#!/bin/bash
input=$(cat)
pct=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
echo "ctx: ${pct}%"
The hook receives context_window.used_percentage, model.id, cost.total_cost_usd, and more. Restart the byte to pick up the change.
The daemon reads only the footer below the input box, and recognizes two shapes there: ctx: N% (above) or a progress bar followed by N% (e.g. █████░░░░░ 54%, as the GSD statusline renders). Percentages anywhere else on screen — conversation text, Claude Code's own "Context left until auto-compact" line (which counts remaining) — are ignored. Whatever number your statusline prints is the one usage shows and the 70% context alert compares against; a statusline that rescales (GSD maps the auto-compact point to 100%) moves the alert with it.
Verify inbound end-to-end: grep -E "main bridge connected|running tmux new-session" ~/hydra-<platform>-daemon.log
| File | Purpose |
|---|---|
gateway.ts | ChatGateway interface and shared types |
discord-gateway.ts | Discord implementation (discord.js) |
slack-gateway.ts | Slack implementation (@slack/bolt Socket Mode) |
daemon.ts | Platform-agnostic message router and session manager |
bridge.ts | MCP relay between Claude and daemon (unix socket ↔ stdio) |
cli/hydra.ts | CLI entry point — routes commands |
cli/helpers.ts | Config resolution, tmux wrappers, socket comms, compile check |
cli/lifecycle.ts | Lifecycle commands: up/down/restart/watchdog/preflight/install |
cli/peek.ts | View live sessions via tmux linked windows with filtered chooser |
Logs land at ~/hydra-<platform>-daemon.log and ~/hydra-<platform>-byte.log.
model is a plain property, not Raindrop's built-in model dimension — that
dimension is fed from ai_data, which this does not send. Filterable, but it
won't group Events or Costs by model for you.RAINDROP_USER_ID. On a shared
install a colleague's session is reported under your id — only their 👍/👎 is
dropped, not their sessions.