Discord webhooks let my remote Claude Code agent send me images
Written on
I’ve been using Claude Code’s remote control mode a lot recently. You start it on your laptop with claude --rc, and then you can control it from your phone or from the web. I can be out of the house and still poke at a project, ask Claude to run some tests, or fix a bug.
There’s one problem though: the Claude app on my phone can’t show images. Neither can the web version as far as I can tell. That’s usually fine until it isn’t. I ran into this recently when I had asked Claude to make some changes to a personal project before I went out, and then tried to check on the work from my phone once I was already out of the house. The project uses vitest browser tests, which take screenshots of the pages they’re testing. After Claude’s changes, vitest had generated diff images showing what shifted on the page. I wanted to see what the diff actually looked like, but there’s no way to get an image to show up on my phone through the Claude app.
My laptop was still running because I keep it awake with Amphetamine when I’m out of the house, so the agent was happily doing its thing. But the whole point of remote control is that I don’t want to go home just to look at one image. So I needed a way for the agent to send me images.
My first thought was to have the agent upload the image somewhere and send me a link. But that means setting up an S3 bucket, paying for storage, configuring access, and getting the agent to generate signed URLs. That’s a lot of moving parts just to see an image on my phone.
Then I realized I could just use a Discord webhook. I have a Discord server where I’m the only member (don’t judge), so I created a webhook in one of its channels and wrote a small script around it:
discord-send 'Build finished successfully'
discord-send --attach ./screenshot.png 'Here is the rendered page' The script reads the webhook URL from ~/.config/discord-send/webhook and either POSTs a plain message or uploads the file as an attachment. That’s it. Here’s the whole thing, it’s a Bun script:
#!/usr/bin/env bun
import { basename, join } from "node:path";
import { homedir } from "node:os";
const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
const webhookFile = join(configDir, "discord-send", "webhook");
function usage() {
console.error(
"Usage: discord-send [--attach <file>] <message>\n" +
"\n" +
"Sends a message to a Discord channel via webhook.\n" +
`Reads the webhook URL from ${webhookFile}.`,
);
}
const args = Bun.argv.slice(2);
let attachPath = null;
const messageParts = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--attach") {
attachPath = args[++i];
if (!attachPath) {
console.error("Error: --attach requires a file path");
process.exit(2);
}
} else if (arg.startsWith("--attach=")) {
attachPath = arg.slice("--attach=".length);
} else if (arg === "-h" || arg === "--help") {
usage();
process.exit(0);
} else {
messageParts.push(arg);
}
}
const message = messageParts.join(" ");
const webhookFileHandle = Bun.file(webhookFile);
if (!(await webhookFileHandle.exists())) {
console.error(
`Error: webhook file not found: ${webhookFile}\n` +
"Create it and write your Discord webhook URL inside.",
);
process.exit(1);
}
const webhookUrl = (await webhookFileHandle.text()).trim();
if (!webhookUrl) {
console.error(`Error: webhook file is empty: ${webhookFile}`);
process.exit(1);
}
if (!message && !attachPath) {
usage();
process.exit(2);
}
let body;
const headers = {};
if (attachPath) {
const file = Bun.file(attachPath);
if (!(await file.exists())) {
console.error(`Error: file not found: ${attachPath}`);
process.exit(1);
}
const filename = basename(attachPath);
const payload = { attachments: [{ id: 0, filename }] };
if (message) payload.content = message;
const form = new FormData();
form.append("payload_json", JSON.stringify(payload));
form.append("files[0]", file, filename);
body = form;
} else {
headers["Content-Type"] = "application/json";
body = JSON.stringify({ content: message });
}
const response = await fetch(webhookUrl, { method: "POST", headers, body });
if (!response.ok) {
const text = await response.text();
console.error(
`Error: Discord returned ${response.status} ${response.statusText}\n${text}`,
);
process.exit(1);
}
console.log("Message sent successfully"); Drop it on your PATH, create a webhook in a Discord channel of your choosing, write the URL into ~/.config/discord-send/webhook, and you’re done.
Then I added a note to my ~/.claude/CLAUDE.md telling the agent that the script exists and when to use it. Now if I ask Claude to show me something, it shoots it over to Discord and I see it on my phone immediately.
Oh and I also realized I could use this for notifications. Long-running task? Have the agent ping me when it’s done. That went in the root instructions too.
The one thing I can’t do is talk back. Discord is one-way: my agent can send me things, but nothing I type on Discord gets back to Claude. That’s slightly annoying, but honestly I think it’s the right call. I trust Claude’s remote control mode because I set it up and know what it does. I don’t want to wire some other service into my agent where a stray Discord message would effectively be running code on my laptop.
Apparently Claude Code Channels already exists and can do two-way control through Discord, Telegram, and iMessage. I only learned about it while writing this post. But that’s the full two-way door, and it’s exactly the thing I was avoiding. If someone manages to message my agent on Discord, suddenly I’m busted. I only want the notification half, not the chat-control half, so a dumb webhook is still the right shape for me.
Also, the script is generic. Claude Code happens to be what I wrote it for, but it’s just a CLI that takes a message and an optional file. Any script, cron job, or CI pipeline can call it the same way.
