Remote Claude – a Discord relay for Claude Code
How a thin Discord bot became a full remote-control layer for Claude Code, and the Windows constraints that shaped every design decision.
The premise is simple: Claude Code runs on your machine, and you’re not always at your machine. A thin Discord bot fixes that – DM it, it spawns claude -p, pipes the response back. Should be 200 lines.
It’s over 3,000 now.
This predates Anthropic’s official Channels SDK by about a month – they shipped it in March with a Discord plugin as a reference example for exactly this problem. I was flying blind on wrapping claude -p and bridging it over Discord, so some of the solutions here are hackier than they’d need to be today – the ~/.claude/CLAUDE.md memory write is a good example.
The Windows constraint that changed the architecture
The first obvious approach is putting the prompt on the command line:
claude -p "do the thing" Works until the prompt gets long. Windows shells cap at 8,191 characters, and a real conversation with embedded code or file content hits that fast. Escaping is the other problem – backticks, quotes, and curly braces in a prompt all do things you don’t want once the shell gets hold of them.
So prompts go through stdin instead:
const proc = spawn("claude", args, {
cwd: state.cwd,
shell: true,
env: cleanEnv,
stdio: ["pipe", "pipe", "pipe"],
});
// Pipe prompt via stdin to avoid shell escaping issues
proc.stdin?.write(prompt);
proc.stdin?.end();The system prompt – which carries Discord interface awareness, recalled history, pending screenshots, recent slash commands – has the same problem, but worse. It’s always long. --append-system-prompt-file takes a file path, so it gets written to a temp file per request and cleaned up after. That temp file is the most unglamorous part of the codebase and also the thing I’d be most annoyed to lose.
Killing the whole tree
Node’s .kill() terminates the shell process. Claude Code is a child of that shell. .kill() leaves Claude Code running, burning API credits, writing files, completing whatever the cancelled request was doing.
The fix is a Windows-only branch:
export function cancelCurrentRequest(): boolean {
if (activeProc) {
const pid = activeProc.pid;
if (pid) {
try {
execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore" });
} catch {
// Process may have already exited
activeProc.kill();
}
} else {
activeProc.kill();
}
activeProc = null;
return true;
}
return false;
}/F force-kills, /T kills the child tree. The fallback to .kill() handles the race where the process exits between the check and the execSync call.
I’d assumed .kill() on a spawned process worked the way you’d expect. It doesn’t, on Windows, when shell: true. Good thing to learn before you’ve shipped something production.
Growing past a relay
The relay itself took a weekend. One giant follow-up commit added the things I actually wanted – restart, recall, remember, cancel, image support, streaming. Everything since has been polish.
/recall fetches DM history (paginated, 100 messages at a time because Discord’s API caps it there), filters for a query, formats the results as timestamped conversation pairs, and injects them into the next request’s system prompt via --append-system-prompt-file. The injection is one-shot – Claude’s --continue flag keeps the context alive for the rest of the session without re-injecting it.
/screenshot runs a PowerShell script – System.Windows.Forms.Screen for monitor bounds, System.Drawing.Graphics for the pixel capture – saves the result to a temp file, sends it back to Discord, and drops a copy into .temp-attachments with a path reference in the system prompt. Next DM, Claude reads the file as part of its context.
/remember writes to ~/.claude/CLAUDE.md under a ## Discord Bot Memories section. Every new session picks it up. Not elegant – it’s just appending dated bullet points – but it means Claude knows things like which cwd is the active project without being told again.
The Discord presence is set to the current working directory on startup and on every /cwd change. A small thing. I spend enough time away from the desk that glancing at a Discord sidebar and seeing F:/Projects/Other/remote-claude is actually useful.
The nested-process environment problem
One thing I didn’t expect: running Claude Code from inside a Claude Code session breaks. The subprocess detection looks at environment variables – CLAUDECODE, CLAUDE_CODE_ENTRYPOINT, CLAUDE_CODE_SESSION_ID and several others – and either refuses to start or behaves strangely.
The fix is stripping those env vars before spawning:
const cleanEnv = { ...process.env };
for (const key of [
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT", "CLAUDE_AGENT_SDK_VERSION",
"CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES", "CLAUDE_CODE_ENABLE_ASK_USER_QUESTION_TOOL",
"CLAUDE_CODE_MODULE_PATH", "CLAUDE_CODE_SESSION_ID",
]) {
delete cleanEnv[key];
} I found that list by running the bot from a terminal where Claude Code was already active and watching what failed. Probably not exhaustive.
What I’d do differently
The restart wrapper (src/restart.ts) runs a type check before each start and backs off after three consecutive crashes. That part I’d keep. What I’d rethink is the streaming display: right now it edits a Discord message in place at most once per second, which creates a janky “watching text update” effect. The better model is probably a single ephemeral status message for tool activity and a separate final message for the response text. The current approach mixes them in order-of-arrival, which gets confusing on long multi-tool runs.
The memory system writing to ~/.claude/CLAUDE.md is also a hack. It works because that’s exactly where Claude Code reads global context from, but it means the Discord bot is directly editing a file that other Claude sessions also use. Works fine until it doesn’t.
The bigger gaps are conversation continuity and per-directory session resume. --continue keeps the last session alive, but there’s no way to jump back into a specific past conversation or pick up work parked under a given project path. A rewrite would sit inside Channels – probably a fork of the official Discord plugin rather than using it straight, because the QOL features this bot has built up (recall, screenshot injection, the memory system, cwd presence) aren’t in the reference implementation. Channels handles the session model; I port the rest on top.
Repo at github.com/joshdevous/remote-claude. You can spin it up for yourself if you like :D
Projects
what this writeup is about