Last week I wanted full control over my Discord profile’s rich presence. The default only shows what Discord detects as your “current game” — I wanted custom state, details, buttons, timestamps, and automatic profile rotation.
So I built discord-rpc-tui: a custom Discord Rich Presence manager using Ink (React for the terminal) that runs as a systemd service.
Architecture
Ink TUI (React terminal) ← or headless mode via systemd
│
RPCManager ← @xhayper/discord-rpc with auto-reconnect
│
Discord IPC socket ← /run/user/1000/discord-ipc-0
The app has two modes:
- TUI mode — interactive Ink terminal UI with status bar, profile list, and event log. Use it when you want to see what’s happening.
- Headless mode — runs as a daemon via systemd, logs to journalctl. Starts on login automatically.
[5:19:58 PM] ○ Connecting...
[5:19:58 PM] Connected to Discord
[5:19:58 PM] Activity set: Coding
Profile: Coding
State: Building something cool
Details: TypeScript | Ink TUI
[●] q:quit Space:pause n:next r:reload
Key Features
Activity Rotation
Set multiple profiles that rotate automatically at a configurable interval. Each profile has its own state, details, activity type, buttons, and images:
{
"profiles": [
{
"name": "Coding",
"activity": {
"state": "Building something cool",
"details": "TypeScript | Ink TUI",
"startTimestamp": true,
"type": 0
}
},
{
"name": "Gaming",
"activity": {
"state": "Exploring Hyrule",
"details": "Zelda: Tears of the Kingdom",
"largeImageKey": "zelda",
"buttons": [{ "label": "Watch Stream", "url": "https://twitch.tv/..." }]
}
}
],
"rotationInterval": 600
}
Auto-Reconnect with Exponential Backoff
If Discord restarts or the IPC socket disconnects, RPCManager automatically retries with backoff:
1s → 2s → 4s → 8s → 16s → max 30s
Discord Detection
A DiscordDetector polls the IPC socket at /run/user/1000/discord-ipc-0 every 5 seconds. When Discord closes, it pauses RPC. When Discord opens, it auto-connects.
Activity Title
Each profile now supports a name field that appears as the Discord activity title — e.g., “Playing easy-rag” or “Listening to My Playlist”. This gives full control over how your presence is labeled:
{
"name": "Coding",
"activity": {
"name": "easy-rag",
"state": "Building something cool",
"details": "TypeScript • Ink TUI"
}
}
The Bug That Taught Me Something
The trickiest issue was discovering that @xhayper/discord-rpc’s connect() method doesn’t emit the ready event. You need to call login() instead — which internally calls connect() and then emits ready when no OAuth scopes are needed:
❌ this.client.on('ready', handler);
await this.client.connect(); // ready NEVER fires
✅ this.client.on('ready', handler);
await this.client.login(); // ready fires!
The login() method checks if OAuth scopes are required. For simple SET_ACTIVITY, no authentication is needed — just a Client ID. But the name login() is deceptive; it’s actually the correct way to establish a full RPC session even without auth.
Tech Stack
| Component | Choice | Why |
|---|---|---|
| TUI Framework | Ink 7 | React for terminal, Flexbox layout, first-class keyboard input |
| RPC Library | @xhayper/discord-rpc | Active maintenance, TypeScript, IPC + WebSocket transport |
| Config | Zod + JSON | Shared dependency with AI SDK, excellent type inference |
| Runtime | Node.js 22 | ESM native, systemd integration |
| Tests | Vitest — 37 tests across 4 core modules | 3 new test files (activity-rotator, discord-detector, rpc-manager) |
| Auto-start | systemd —user | Standard Linux user service, restart on failure |
Try It
git clone https://github.com/fxckcode/discord-rpc-tui.git
cd discord-rpc-tui
pnpm install
bash install.sh
# Edit ~/.config/discord-rpc-tui/config.json with your Discord Client ID
The install script sets up everything: builds the binary, creates the config, installs the systemd service, and optionally enables auto-start.
The project is MIT licensed and open for contributions on GitHub.