Show the participants of your Discord voice channel on a macro deck — a grid of labeled, tappable buttons — one button per person, lit up when they speak and badged when they mute or deafen.
The macro deck can be virtual (the browser-based debug deck that ships today) or, eventually, a physical device (Elgato Stream Deck, OpenDeck, StreamDock / AJAZZ, …). Deckord is built so the device is the only replaceable part: everything above it — reading Discord voice state, assigning people to slots, paginating, rendering labels and badges — is device-agnostic.
Status: MVP+. The end-to-end pipeline runs against the built-in mock voice channel and against a real Discord client — RPC transport, interactive OAuth (
AUTHORIZE→ token-exchange → refresh), and voice subscriptions, all verified live — rendered on the browser debug deck. Avatar caching and server-side PNG rendering (for future physical decks) are done. The physical-device adapters themselves are the main thing not implemented yet. See MVP status.
A physical macro deck is slow to iterate against: you need the hardware, a device SDK, and a real Discord session in a real voice channel just to see whether a label or a badge looks right. That makes the interesting logic — slot assignment, pagination, speaking/mute rendering, reconnection — painful to develop and impossible to test in CI.
The debug deck removes all of that. It is a first-class
IDeckAdapter implementation
(DebugBrowserDeckAdapter)
that paints the deck into a browser window over a local WebSocket and sends virtual
button presses back. Combined with the mock voice provider
(MockVoiceProvider), the
entire system runs with no Discord client and no hardware present. You develop
and test the whole pipeline in the browser; swapping in a physical adapter later is
a change in one place (DeckordService)
and nowhere else.
Deckord is a pnpm monorepo. The service process owns the pipeline; the debug deck is a separate browser app that talks to it over a loopback WebSocket.
┌──────────────────────────────────────────────────────────────┐
│ deckord-service (Node) │
│ │
Discord desktop │ ┌───────────────┐ │
client (RPC over │ │ DiscordVoice │ ─┐ │
local IPC pipe) ────┼──▶│ Provider │ │ graceful fallback │
│ └───────────────┘ ├──▶ VoiceService ──▶ deck-core ──▶ ... │
│ ┌───────────────┐ │ (normalized (SlotManager: │
(no Discord? use │ │ MockVoice │ ─┘ VoiceChannel order + pages + │
the built-in mock) │ │ Provider │ State) status slot) │
│ └───────────────┘ │ │
│ ▼ │
│ renderer ──▶ DeckAdapterHost ──▶ │
│ (titles, badges, (diffs slots, │
│ avatar, a11y) pushes changes) │
│ │ │
│ DebugBrowserDeckAdapter │
│ (an IDeckAdapter — REPLACEABLE) │
│ │ │
│ WsServer │
│ (loopback WebSocket, optional │
└──────────────────────────────── shared token) ───────────────┘
│ ▲
slot/voice/status ▼ │ │ ▲ button + mock commands
▼ │
┌──────────────────────────────────────────────────────────────┐
│ deckord-debug-deck (browser / React) │
│ renders the button grid, sends virtual presses, │
│ drives the mock via debug controls │
└──────────────────────────────────────────────────────────────┘
Pipeline in one line
(from DeckordService):
Discord/mock → VoiceService → deck-core → renderer → deck-adapter → debug browser deck
The adapter is the replaceable bottom layer. deck-core must never depend on a
concrete adapter, and an adapter must never contain Discord logic. Because the
debug adapter and any future physical adapter both implement the same
IDeckAdapter contract, replacing the
browser deck with hardware means constructing a different adapter in
DeckordService — nothing upstream
changes.
| Workspace | Path | What it does |
|---|---|---|
@deckord/shared |
packages/shared | Provider-agnostic domain types (VoiceUser, VoiceChannelState, DeckSlot, DeckLayout), typed errors, Result, and a small logger. No I/O. |
@deckord/ipc-contract |
packages/ipc-contract | The local WebSocket wire protocol: Zod schemas + encode / decode* for every message that crosses the service ↔ UI boundary. Also holds the default host/port/path constants. |
@deckord/discord-rpc |
packages/discord-rpc | Discord IPC transport, RPC handshake/request/dispatch, voice-state normalization, the interactive OAuth AUTHORIZE → token-exchange → refresh flow (DiscordAuthenticator), and a token store. Pending live verification against a real client. |
@deckord/deck-core |
packages/deck-core | Pure logic: turn a VoiceChannelState into a logical DeckLayout with stable slot ordering (AssignmentPolicy), pagination (PageManager), and a reserved status/page slot (SlotManager). No I/O, no timers. |
@deckord/renderer |
packages/renderer | Enrich a logical layout with presentational fields: titles, subtitles, avatar source, status badges, accessibility labels, and a deterministic identicon. Browser-safe (no native deps). |
@deckord/image-renderer |
packages/image-renderer | Node-only PNG rasterizer (SlotImageRenderer, backed by @napi-rs/canvas) that turns a rendered slot into button pixels for physical decks. The browser deck renders via CSS instead. |
@deckord/deck-adapter |
packages/deck-adapter | The replaceable device layer: the IDeckAdapter contract, a change-diffing DeckAdapterHost, and the MVP DebugBrowserDeckAdapter. |
deckord-service |
apps/deckord-service | The Node orchestrator: config, logging, voice providers + fallback, avatar resolver, WebSocket server, and the pipeline wiring in DeckordService. |
deckord-debug-deck |
apps/deckord-debug-deck | The Vite + React browser deck: renders the button grid, sends virtual presses, and exposes mock controls. |
The pipeline is complete end-to-end in both mock mode and against a real Discord client, rendered on the browser debug deck. The following are still absent:
- Physical deck adapters. Only
DebugBrowserDeckAdapterexists. Elgato / OpenDeck / StreamDock adapters are future work behind the sameIDeckAdapterinterface. - Discord writes. The RPC scopes are read-only; button presses drive debug-only behavior (page switching, local selection) and never mute/move anyone in Discord.
Typed error codes for these conditions live in
packages/shared/src/errors.ts.
-
Node.js
>=20(seeenginesin package.json). -
pnpm — this repo pins
pnpm@10.13.1via thepackageManagerfield. The simplest way to get the right version is Corepack (bundled with Node):corepack enableCorepack will then use the pinned pnpm automatically inside this repo.
No Discord client and no hardware are required to run in the default mock mode.
pnpm installStart the service and the debug deck together:
pnpm devThis runs both processes concurrently:
- the service (
tsx watch), which opens the WebSocket API onws://127.0.0.1:8787/deck, and - the debug deck (Vite dev server).
Then open the debug deck in your browser:
http://127.0.0.1:5173
The browser app auto-connects to the WebSocket at ws://127.0.0.1:8787/deck and
auto-reconnects if the service isn't up yet. On connect it receives a full snapshot
and then live updates.
You can also run the two halves separately:
pnpm dev:service # just the Node service
pnpm dev:debug-deck # just the browser deckWith no Discord environment variables set, the service resolves to the mock voice
provider (resolveInitialProvider). It
seeds a fake voice channel ("Mock Lounge") and, by default, drives speaking activity
on a timer so the deck lights up on its own. The debug UI's mock controls let you
add/remove users, toggle mute/deafen, trigger random speaking, start/stop the
simulation, and reset the channel — see the command list in
MockCommandSchema.
The full Discord RPC path is implemented (transport, interactive AUTHORIZE →
token-exchange → refresh, subscriptions). See
docs/discord-rpc.md for the app-registration and testing walkthrough.
Interactive flow — register a Discord app (client id + secret + redirect URI), add
yourself under App Testers (the rpc scope is whitelist-only), run the Discord
desktop client, then:
export DISCORD_CLIENT_ID=your_application_client_id
export DISCORD_CLIENT_SECRET=your_application_client_secret
# DECKORD_PROVIDER defaults to `auto`, which already picks Discord RPC when a
# client id + secret are present. Set DECKORD_PROVIDER=discord-rpc to force it.
pnpm dev:service # approve the consent prompt in Discord once; the token is cachedFast path for testing without the OAuth round-trip — supply a pre-obtained token:
export DISCORD_CLIENT_ID=your_application_client_id
export DISCORD_ACCESS_TOKEN=a_token_with_the_scopes_below
pnpm devRequirements and behavior:
- Scopes (read-only, from
MVP_SCOPES):identify,rpc,rpc.voice.read. Deckord never requestsrpc.voice.write. - The Discord desktop client must be running and joined to a voice channel; the
transport connects to its local
discord-ipc-{0..9}pipe/socket. - Graceful fallback. If the Discord provider can't start — Discord not running,
no token, no selected voice channel, handshake failure — the service logs a warning
status (
PROVIDER_SWITCHED_TO_MOCK) and continues on the mock provider, so the rest of the system keeps running unchanged. SeeVoiceService. - Provider selection is controlled by
DECKORD_PROVIDER. Inauto(the default), Discord RPC is attempted only when bothDISCORD_CLIENT_IDandDISCORD_ACCESS_TOKENare present; otherwise mock.
All are optional; the defaults produce a working mock-mode setup on loopback.
| Variable | Default | Purpose |
|---|---|---|
DECKORD_PROVIDER |
auto |
Provider preference: auto, mock, or discord-rpc. Under auto, Discord RPC is used only when DISCORD_CLIENT_ID and (DISCORD_ACCESS_TOKEN or DISCORD_CLIENT_SECRET) are present. |
DECKORD_WS_HOST |
127.0.0.1 |
WebSocket bind host (loopback by default). |
DECKORD_WS_PORT |
8787 |
WebSocket port. |
DECKORD_WS_PATH |
/deck |
WebSocket path. |
DECKORD_WS_TOKEN |
(none) | Optional shared secret. When set, clients must connect with ?token=…; connections without it are rejected. When unset, the service logs a warning that the API is unauthenticated. |
DECKORD_MOCK_AUTOSTART |
true |
Whether the mock starts its speaking loop automatically (1/true to enable). |
DECKORD_MOCK_USERS |
5 |
Number of fake users the mock channel seeds. |
DECKORD_MOCK_SPEAKING_MS |
1600 |
Interval (ms) of the mock speaking loop. |
DECKORD_LOG_LEVEL |
info |
Log level: debug, info, warn, or error. |
DECKORD_APP_NAME |
Deckord |
Display/app name used in logs and the status slot. |
DISCORD_CLIENT_ID |
(none) | Discord application client id (required for RPC); sent in the handshake. |
DISCORD_ACCESS_TOKEN |
(none) | Pre-obtained OAuth access token — the "fast path" that skips the interactive AUTHORIZE flow. |
DISCORD_CLIENT_SECRET |
(none) | Enables the interactive OAuth AUTHORIZE exchange and token refresh. Trusted local service only — never ship it to a browser client. |
DISCORD_REDIRECT_URI |
http://127.0.0.1/callback |
Redirect URI used in the OAuth token exchange; must match one registered on the Discord application. |
DECKORD_TOKEN_PATH |
~/.deckord/discord-token.json |
Where the OAuth token JSON is persisted (plaintext, mode 0600 in the MVP). Defaults to {DECKORD_DATA_DIR}/discord-token.json. |
DECKORD_DATA_DIR |
~/.deckord |
Base data directory; used to derive the default DECKORD_TOKEN_PATH. |
The debug deck (browser) reads two optional Vite env vars, defaulting to loopback
(see DeckSocket.ts):
VITE_WS_URL (default ws://127.0.0.1:8787/deck) and VITE_WS_TOKEN (appended as
?token=… to match DECKORD_WS_TOKEN).
Run from the repo root (package.json):
| Command | Description |
|---|---|
pnpm install |
Install all workspace dependencies. |
pnpm dev |
Run the service and the debug deck concurrently. |
pnpm dev:service |
Run only the Node service (tsx watch). |
pnpm dev:debug-deck |
Run only the browser debug deck (Vite). |
pnpm test |
Run the test suite once (Vitest). |
pnpm test:watch |
Run Vitest in watch mode. |
pnpm lint |
Lint the workspace (ESLint); pnpm lint:fix to autofix. |
pnpm build |
Build all packages (pnpm -r run build). |
pnpm typecheck |
Type-check all packages (pnpm -r run typecheck). |
pnpm format |
Format with Prettier; pnpm format:check to check only. |
Deckord is intentionally conservative about what it touches:
- No user token / not a self-bot / no client modification. Deckord talks to the official Discord RPC interface over the local IPC pipe. It is not a user-token bot, and it does not modify or inject into the Discord client.
- No message reading. The requested scopes are read-only voice scopes
(
identify,rpc,rpc.voice.read) — seeMVP_SCOPES. Deckord never requestsrpc.voice.writeand performs no writes to Discord. - Minimal data. Only voice presence and per-user state is consumed: who is in the
channel, whether they are speaking, and their mute/deafen/suppress flags, plus a
display name and an avatar hash / URL. See
VoiceUser. - Data stays local. State never leaves your machine. The service exposes only a
loopback WebSocket (
127.0.0.1by default) consumed by the local debug deck. - Loopback-only transport, optional shared token. The WebSocket binds to
loopback. Set
DECKORD_WS_TOKEN(and the matchingVITE_WS_TOKEN) to require a shared secret before exposing the API anywhere beyond localhost; without a token the service warns that the API is unauthenticated. SeeWsServer. - Token storage caveat (MVP). The file-backed token store in
DiscordAuth.tswrites plaintext JSON (mode0600); an OS-secured store is future work. The client secret, if ever used, belongs only on the trusted local component and must never be shipped to a browser.