dis-claude
by 0rnot
README.md
# dis-claude
**Run Claude Code from your phone.** One Discord forum thread is one Claude Code
session — reports arrive on their own, approvals are two taps, and it is still
the same `claude` process you can `tmux attach` to from your terminal.
[](LICENSE)
[](https://www.python.org/)
[](https://github.com/Rapptz/discord.py)
**日本語** · [README.ja.md](docs/README.ja.md) | **中文** · [README.zh-CN.md](docs/README.zh-CN.md) | **한국어** · [README.ko.md](docs/README.ko.md)
---
## How this differs from the other Claude Code ↔ Discord bridges
There are already a good number of these, and several are excellent. Read this
section first so you can pick the right one rather than the first one.
The projects worth comparing against include
[raylin01/DisCode](https://github.com/raylin01/DisCode),
[DoBuDevel/discord-agent-bridge](https://github.com/DoBuDevel/discord-agent-bridge),
[chadingTV/claudecode-discord](https://github.com/chadingTV/claudecode-discord),
[ecmulli/claudecord](https://github.com/ecmulli/claudecord),
[fredchu/discord-claude-code-bot](https://github.com/fredchu/discord-claude-code-bot),
[thcapp/claude-discord-bridge](https://github.com/thcapp/claude-discord-bridge),
[ebibibi/claude-code-discord-bridge](https://github.com/ebibibi/claude-code-discord-bridge)
and [JessyTsui/Claude-Code-Remote](https://github.com/JessyTsui/Claude-Code-Remote).
### The three real differences
**1. Reporting comes from Claude Code's hook lifecycle — all of it.**
Most bridges get their output one of three ways: polling `tmux capture-pane`
(discord-agent-bridge samples every 30 seconds and diffs the screen), streaming a
subprocess driven by the Agent SDK (claudecode-discord), or wiring a couple of
hooks (DisCode uses `PreToolUse`, `SessionStart`, `SessionEnd`).
This project subscribes to the whole lifecycle:
`SessionStart` · `UserPromptSubmit` · `Stop` · `StopFailure` · `Notification` ·
`TaskCreated` · `TaskCompleted` · `PostToolUse` · `PostToolUseFailure` ·
`SubagentStart` · `SubagentStop` · `PreCompact` · `PostCompact` ·
`PermissionDenied` · `CwdChanged` · `SessionEnd` · `PermissionRequest`
That is why you do not have to write "please keep the user informed" in a system
prompt. Reporting is not something the model chooses to do, so it cannot be
forgotten and it cannot be overdone. Screen polling cannot see task boundaries or
which subagent is doing what; this can.
**2. It is your actual CLI session, not a re-implementation of it.**
Claude Code runs inside `tmux` exactly as you would run it yourself. So:
* `tmux attach -t dis_<thread-id>` and carry on typing, mid-session.
* The interactive TUI works from Discord — `/cc-cmd model`, then `/cc-key Down Enter`.
* The bot can restart, or crash, and your session keeps running. On the way back
up it re-discovers live sessions from `tmux list-sessions`.
Bridges built on the Agent SDK run *a* Claude, not *your* Claude Code session.
That is a legitimate design with its own advantages; it is simply a different
thing, and worth knowing before you choose.
**3. Approvals answer the CLI's own permission prompt.**
When you press **Allow**, the bot sends `Enter` into the real permission prompt
sitting in your terminal. **Deny** sends `Escape`. Claude Code's permission system
stays in charge and stays authoritative — there is no `--dangerously-skip-permissions`,
and no bot-side allowlist standing in for it.
On top of that sits a policy layer, because being asked about everything is how
approval stops working:
| Situation | Result |
| --- | --- |
| Not destructive | allowed silently |
| Destructive, outside your protected paths | allowed (`rm -rf ./build` in a repo) |
| Destructive **and** aimed at a protected path | **ask**, with the full command |
| `rm -rf /`, `rm -rf $VAR/`, `mkfs`, writes to `/dev/sda`… | **always ask** |
Uncertainty always resolves to *ask*. A missing config, an unparseable command or
an exception never produces an allow.
### Everything else that is different
* **Claude can ask *you* something.** The `ask_user` MCP tool blocks until you
answer, and renders your options as buttons. Most bridges are one-directional.
* **Live progress in a single edited message** — task counter, the last few tool
calls, and one line per running subagent. Not a wall of new messages.
* **Secrets are masked** before a command reaches your phone: bearer tokens,
`--password`, `sk-`/`ghp_`/`xox`/`AKIA` shapes, and credentials in URLs.
* **Nothing is ever truncated.** Long approvals and reports are posted whole, as
file attachments. Half of a diff is not something you can approve.
* **The hooks import nothing but the standard library** and always exit 0. If the
bot is down or the queue is unwritable, Claude Code does not notice.
* **No network between the pieces.** Hooks and bot talk through a file queue with
atomic writes, so a bot restart loses nothing and a hook never blocks.
* **Localised UI** — English and Japanese ship in the box, and a language is one
dict in [`i18n.py`](src/disclaude/i18n.py).
* **Written in Python.** Every other bridge I found is TypeScript/Node.
### Where the others are stronger
Being fair about this is more useful to you than a clean sweep would be.
* **Multiple machines from one server** — DisCode and claudecode-discord both do
this properly. Here, one bot serves one machine.
* **Container isolation** — discord-agent-bridge can run agents in Docker.
* **Non-Discord transports** — Claude-Code-Remote also does email and Telegram.
* **Windows** — this project needs `tmux`, so it wants Linux, macOS or WSL.
---
## What it looks like
```
#claude-code (forum channel)
│
├─ 🟢 [3/5] fix token expiry in login.py ← name and tag update themselves
│ │
│ │ you › the session keeps 401ing after an hour, have a look
│ │
│ │ 🤖 `[1/5]` start: reproduce the expiry
│ │ -# 🔎 search: refresh_token
│ │ -# 📄 read: auth.py
│ │ -# 👤 agent 1 🛠️ bash: pytest tests/test_auth.py -x
│ │
│ │ ┌────────────────────────────────────────┐
│ │ │ **Approval needed** `Bash` — rm -r / -f │ ← red card, always rings
│ │ │ ```bash │
│ │ │ rm -rf ~/.config/myapp/cache │
│ │ │ ``` │
│ │ │ [ Allow ] [ Deny ] │
│ │ └────────────────────────────────────────┘
│ │
│ │ 🤖 Fixed. `refresh_token` was compared against `iat` instead of `exp`,
│ │ so any token older than an hour failed the check. auth.py:88.
│ │ Tests pass.
│
└─ ✅ [done] audit the queue drain path
```
---
## Quick start
Five minutes, assuming you already have Claude Code working.
### 1. Install
```bash
pip install git+https://github.com/0rnot/dis-claude
```
(A PyPI release is coming; until then, install from the repository.)
You also need `tmux` (`apt install tmux` / `brew install tmux`) and, obviously,
the `claude` CLI on your `PATH`.
### 2. Make a Discord bot
1. [Discord Developer Portal](https://discord.com/developers/applications) →
**New Application** → **Bot** → **Reset Token**, and keep the token.
2. Under **Bot**, enable **Message Content Intent**.
3. **OAuth2 → URL Generator**: scopes `bot` and `applications.commands`;
permissions *View Channels*, *Send Messages*, *Send Messages in Threads*,
*Create Public Threads*, *Manage Threads*, *Embed Links*, *Attach Files*,
*Add Reactions*, *Read Message History*, *Manage Messages*.
4. Invite it to your server.
5. Create a **Forum** channel for it. Right-click → *Copy Channel ID*
(Developer Mode must be on in Discord's Advanced settings).
### 3. Wire it up
```bash
dis-claude init
```
That writes `~/.dis-claude/config.json`, merges the hook entries into
`~/.claude/settings.json` (backing it up first, and leaving anything already there
alone), and registers the MCP server with `claude mcp add`.
Then fill in the two ids and export your token:
```bash
$EDITOR ~/.dis-claude/config.json # forum_channel_id, owner_user_id
export DISCORD_TOKEN='...'
```
### 4. Check and run
```bash
dis-claude doctor
dis-claude bot
```
### 5. Use it
Post a thread in the forum channel and send a message. That first message starts a
session and becomes its first instruction. Everything after that goes to the
running session.
To choose a working directory, start the **thread name** with `cwd:`:
```
cwd:/home/you/projects/api token expiry investigation
```
---
## Controls
| Want to | Do |
| --- | --- |
| Start a session | Post in the forum and send a message |
| Give an instruction | Send a message in the thread |
| Send something long | `/cc-long` (opens a dialog; newlines survive) |
| Answer a question | Tap a button, or reply, or long-press → **Answer this** |
| **Approve an action** | Tap **Allow** / **Deny** |
| Send a file | Attach it — Claude receives the path |
| Run a slash command | `/cc-cmd compact` (no leading `/`) |
| **See the terminal** | `/cc-screen` |
| **Drive the TUI** | `/cc-key Down Down Enter` |
| Review changes | `/cc-diff` |
| Interrupt | `/cc-stop`, or long-press → **Interrupt** |
| End the session | `/cc-end` |
| List sessions | `/cc-status` |
Reactions tell you what happened: ⌨️ delivered, ✅ answer accepted.
---
## How it works
```
Discord file queue your machine
┌────────┐ ┌────────────────────┐ ┌───────────────┐
│ thread │◄──────────►│ queue/outbox ───► │◄────────►│ Claude Code │
│ │ │ queue/inbox ◄─── │ hooks │ in tmux │
│ button │ │ queue/files │ MCP │ │
└────────┘ └────────────────────┘ └───────────────┘
▲ ▲ ▲
│ │ │
dis-claude bot atomic writes tmux send-keys
(discord.py) (no network at all) paste-buffer -p
```
Three processes that only ever meet through files:
* **`dis-claude bot`** — long-lived. Drains the outbox, renders Discord, and pushes
your input into tmux.
* **`dis-claude hook <Event>`** — spawned by Claude Code per event. Standard
library only; always exits 0.
* **`dis-claude mcp`** — spawned by Claude Code. Provides `ask_user`, `tell_user`
and `set_thread_title`.
Writes go to a unique temporary name and are then `os.replace`-d into position, so
a reader never sees a partial file. Nothing listens on a port.
### Details that took a while to get right
Documented because they are not obvious, and because if you fork this you will
otherwise rediscover them the hard way.
* **Thread renames are limited to two per ten minutes.** Ignore it and you get a
429, after which *every* later rename is dropped. Renames are debounced at 330
seconds and state is shown with forum tags instead, which are not rate-limited.
* **Multi-line input needs bracketed paste.** `tmux paste-buffer -p`, then `Enter`
after a short pause. Without `-p`, a message's first line submits and the rest
is stranded. Without the pause, a long paste submits half-read.
* **`tmux new-session` needs `-d`.** Without it tmux attaches to the current
terminal — and under systemd there isn't one, so it dies with
`open terminal failed: not a terminal`.
* **The thread id must be passed as `env VAR=...`, not `export`.** A running tmux
*server* hands new sessions the environment *it* started with, so an exported
variable never arrives.
* **A nested `claude` emits `SessionEnd` too.** `claude mcp list` inside a session
fires it, inheriting the same thread id. Acting on it would kill the tmux session
the real one lives in, so unbound `SessionEnd` events are dropped.
* **Double-press protection cannot be a remembered set.** Sets overflow, and an
evicted entry makes a days-old card look unanswered. The test is "is this thread
still waiting on this ask" — one entry per thread, cleared on answer and expiry.
* **`Stop` can fire before the final message is written.** Reading the tail naively
reports the *previous* turn's answer and silently loses this one. Freshness is
judged by position in the transcript, plus a content fingerprint, with the read
window widened until the turn actually fits.
* **A periodic task that raises stops forever, silently.** Every loop is wrapped.
---
## Configuration
`~/.dis-claude/config.json`. See [docs/configuration.md](docs/configuration.md)
for the full reference; the parts you are most likely to touch:
```jsonc
{
"language": "en", // or "ja"
"discord": {
"forum_channel_id": "...",
"owner_user_id": "..." // leave empty and anyone in the channel can drive it
},
"launcher": {
"default_cwd": "~/projects",
"claude_command": "claude"
},
"permission": {
"auto_allow": true, // false = confirm everything
"protected_paths": ["~/.ssh", "~/.gnupg", "~/.claude"],
"deny_tools": [] // e.g. ["Bash"] to never approve shell remotely
},
"thread": {
"tags": { "working": "", "waiting": "", "done": "", "error": "" }
}
}
```
Forum tags are optional. Create four tags in the channel, paste their ids, and the
channel list shows session state at a glance.
---
## Security
Read this part. You are putting a shell on your phone.
* **Set `owner_user_id`.** Without it, anyone who can see the channel can drive
Claude Code on your machine.
* **A private channel in a private server.** This is not a public bot.
* **Tapping is cheaper than typing**, which cuts both ways: approving on a phone is
a smaller act than approving at a terminal, so it is easier to approve without
reading. That is exactly why approval cards carry the complete command, never a
summary. Read it before you tap.
* **Secret masking is best-effort.** It catches common shapes; it is not a
guarantee, and it is not a reason to route production credentials through here.
* **`deny_tools`** is the hard stop. `["Bash"]` means shell commands can never be
approved from Discord regardless of anything else.
* **The bot only reads attachment paths inside its own home directory.** Queue
messages cannot name arbitrary files.
---
## Troubleshooting
**Nothing appears in Discord.** Check `ls ~/.dis-claude/queue/outbox` — if
files are piling up, the bot is not draining them. If it is empty and nothing
arrived, the hooks are not firing: `dis-claude doctor`.
**Hooks are not firing.** `hooks.<Event>` must be a *list* of matcher groups. An
object there is ignored without a word. Re-run `dis-claude init`.
**A hook is misbehaving.** They swallow their own exceptions on purpose, but they
leave a trace: `~/.dis-claude/hook-errors.log`.
**The session starts but messages do not reach it.** `tmux ls` — you should see
`dis_<thread-id>`. If it is missing, the launch failed; `/cc-end` and send again.
**The thread name stopped updating.** You hit the rename limit. It recovers on its
own within about ten minutes.
**`ask_user` never returns.** Look for an `ask` entry in `queue/outbox`. If it is
not there, the MCP server is stuck rather than the bot.
---
## Contributing
Issues and pull requests are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
Translations are especially welcome and take about ten minutes: copy the `EN` dict
in [`src/disclaude/i18n.py`](src/disclaude/i18n.py), translate the values, add it to
`CATALOGS`. Missing keys fall back to English, so a partial translation is
immediately useful.
## License
MIT. See [LICENSE](LICENSE).
This server cannot be deployed
Maintenance
ActivitySlowing
ResponsivenessNo issues