Skip to main content
Glama
dataterminals

VesktopClaudeBridge

VesktopClaudeBridge

Read your Discord from Claude Code — an Equicord/Vencord userplugin plus a local MCP sidecar.

Point at a message in Discord, and the model reads it. No screenshots, no copy-paste, no bot account.

(Note from dataterminals: There's no chance in hell they'll let me put this on the official Equicord plugins repository because they're convinced that everyone who works with an AI just wants to play 90-day fiancee with the idea of long-term support, so this is the only place you're gonna find this thing. lol. Anyways if you need support in 20 years from now, send me a letter via post-apocalyptic biomechanical carrier-pigeon)

Related MCP server: Discord Bridge MCP Server

Why

Getting Discord content in front of a coding agent currently costs more than it should:

  • Screenshots can't be selected, hold one screen of scrollback, and mangle code blocks — which is exactly what a log is.

  • Copy-paste loses reply chains, timestamps, attachments, and nested backticks.

  • Driving the window with computer-use is slow, and Discord's message list is virtualised: only ~50 messages exist in the DOM at once.

  • Attachments — the actual .log file someone dropped in the channel — sit behind CDN urls with expiring signatures, so passing the url along usually doesn't work.

  • And every "read the logs" costs a round-trip of which channel, how far back.

The client already has all of this resolved in memory. This project just exposes it.

What comes out

── #modding-help · The Forever Winter Modding · text
── 8 messages · 2026-08-01 10:31:02 → 2026-08-02 05:15:00 · times in America/New_York · ids 1399482100000000000 → 1399483900000000000

[10:31:02] Avery: did the pak actually load or is it silently failing again
[10:32:40] Bob: silently failing
   ↳ replying to Avery: "did the pak actually load or is it silently…"
   [attachment] UE4SS.log · 43.2 KB · text/plain · msg 1399482400000000000
[10:33:10] Bob:
```
[2026.08.01-14.32.55:123][  0]LogUE4SS: Starting UE4SS
[2026.08.01-14.32.55:481][  0]LogUE4SS: Error: mod folder not found
```
[10:35:40] Avery: ah that's the -894 path thing (edited)
   👍 2 (you)
── 2026-08-02
[05:15:00] Dana: where should this land?
   [poll] Public or supporters? · 258 votes · final
   [poll]   PUBLIC · 227 (88%) ←you
   [poll]   SUPPORTERS · 31 (12%)

Mentions, channel links, custom emoji and <t:> stamps are resolved to readable text before they leave the client — because that's where the stores are. Code fences are passed through byte-exact.

A page that crosses midnight gets a ── 2026-08-02 rule at the boundary, and its header keeps the date on both ends. One sitting gets neither — a date on every line is pure cost when they all share one, and a bare [05:15:00] under yesterday's header is how a message gets attributed to the wrong day.

Polls are rendered from the message itself, counts and all, with ←you on whatever the signed-in account picked. (you) on a reaction means the same thing. An answer with no tally prints rather than 0: Discord does not always send counts, and "nobody voted" is a different claim from "Discord didn't say".

Times are rendered in this machine's timezone, and the header says which one. Discord hands the client UTC instants; a bare [14:31:02] with nothing marking it gets read as local by every reader downstream — including the model, which will then tell you someone posted four hours later than they did. Set timezone in the sidecar config to any IANA name ("UTC", "Europe/Berlin") to override the default.

Architecture

  Vesktop renderer                    sidecar (node)                Claude Code
 ┌──────────────────┐              ┌───────────────────┐         ┌─────────────┐
 │ Equicord plugin  │──ws 8787────▶│ bridge server     │         │             │
 │  · stores        │◀─── rpc ─────│ MCP server (stdio)│◀───────▶│  discord_*  │
 │  · RestAPI       │              │ HTTP api :8788    │◀─curl───│    tools    │
 │  · mark queue    │              └───────────────────┘         └─────────────┘
 └──────────────────┘

The plugin dials out — renderers can't listen on a socket — and after the handshake the traffic inverts: the sidecar asks, the plugin answers. Reads go through the client's own authenticated RestAPI, so it's the same request the app makes when you scroll up. No bot, no second token.

Install

1. Sidecar

npm run install:sidecar && npm run build

Grab the token it minted (you'll paste this into the plugin):

npm --prefix sidecar run token

2. Plugin

Userplugins are compiled into the client bundle, so this needs an Equicord source build — the prebuilt Vesktop bundle can't load them. This is the one genuinely annoying part, and it has to be redone on every Equicord update.

git clone https://github.com/Equicord/Equicord.git D:/Equicord
.\scripts\install-plugin.ps1 -EquicordPath D:\Equicord -Build

The first run installs Equicord's own dependencies, so give it a few minutes. If pnpm can't self-switch to the version pinned in Equicord's packageManager field, the script retries with whatever pnpm is on your PATH.

Then point Vesktop at D:\Equicord\dist\equibop (Vesktop settings → Vencord Location) and restart it. Enable VesktopClaudeBridge in Equicord settings, paste the token into its settings field, and reload Discord (Ctrl+R).

dist\equibop, not dist and not dist\desktop. Vesktop require()s the Vencord main from that folder, and the two builds are not interchangeable: dist\desktop\patcher.js is the Discord app.asar injector and pulls in discord_desktop_core. Hand Vesktop that one and it hangs on the splash screen with no error in any log. Equibop is Equicord's Vesktop fork, so dist\equibop is the "host app loads Vencord" build.

Vesktop also validates the folder against Vencord's release asset names (vencordDesktopMain.js and friends), which Equicord doesn't build — so a stock Equicord dist is rejected as "invalid". -Build writes those four names in beside the real ones for you. They're copies of build output rather than links, which is why rebuilding has to go through the script — see Living with it.

The token box clears itself on reload — that's intentional. Vencord's cloud settings sync uploads the settings blob to a remote host, so the plugin moves the token into localStorage instead, where it stays on this machine.

One surprise worth knowing: once Vesktop is loading Equicord from a custom Vencord Location, Equicord keeps its settings in %APPDATA%\Equicord\settings, not %APPDATA%\vesktop\settings. That folder is empty until you do this, which makes it easy to spend a while editing a file nothing reads.

Which also means an existing Vencord setup does not come with you — Equicord starts from defaults, and the old config sits there looking current. Moving it over is mostly a copy, with three snags:

  • quickCss.css and themes/ just copy across, but useQuickCss lives in settings.json and has to come too or the CSS loads and does nothing.

  • Some plugins are renamed. VencordToolbox is EquicordToolbox (that's the toolbar QuickCSS toggle, under Themes in its menu), and petpet is PetPet. Same plugin bank otherwise — Equicord is a Vencord superset, so most names match exactly.

  • Don't copy the cloud block. It points at Vencord's cloud host with sync on; Equicord has its own. Carrying it over turns settings sync back on against a different service.

Required plugins (BadgeAPI, NoticesAPI, ContextMenuAPI, SupportHelper) don't need migrating — Vencord manages those itself.

3. Wire up MCP

Build first — step 1, if you skipped it. The path below points into sidecar/dist/, which is gitignored and so isn't in a fresh clone, and a registration aimed at a file that doesn't exist fails at session start with no obvious error.

claude mcp add -s user discord -- node "D:/Github Repositories/VesktopClaudeBridge/sidecar/dist/index.js"

Substitute your own checkout path. Use -s user, or it is scoped to whichever directory you happened to run the command in and silently will not load anywhere else — discord_status then comes back missing in every other project, with nothing on screen to say why.

In Windows PowerShell, claude resolves to claude.ps1, and the parameter binder consumes the bare -- before the script ever sees it, so the subprocess command arrives stripped of its separator and the node ... half is read as more arguments to claude mcp add. Quote the separator to get it through:

claude mcp add -s user discord '--' node "D:/Github Repositories/VesktopClaudeBridge/sidecar/dist/index.js"

Only -- is eaten; -s and the rest pass through fine. The stop-parsing token --% does not help here: it applies only to native commands, so against a .ps1 shim it is passed through as a literal argument and everything after it collapses into a single string. claude.cmd --% ... does work, since that one is native.

Verify with claude mcp get discord; remove with claude mcp remove discord -s user. Then check the bridge itself with discord_status — if it says no_client, Vesktop isn't running or the plugin isn't enabled.

Start it by hand with npm start if you want one running independently — otherwise Claude Code spawns it for you when a session begins.

Several can run at once. The plugin dials exactly one socket, so exactly one process owns the Discord connection — whoever starts first. Everyone after that detects the owner and proxies through it over /rpc, which is how Claude Code and Claude Desktop can both use this at the same time. The plugin reconnects on its own within ~15s of an owner appearing, so you don't need to reload Discord when sessions come and go.

Claude Desktop

Same server, registered in %APPDATA%\Claude\claude_desktop_config.json:

{ "mcpServers": { "discord": { "command": "node",
  "args": ["D:\\Github Repositories\\VesktopClaudeBridge\\sidecar\\dist\\index.js"] } } }

Browser claude.ai can't use this: it needs a remote MCP endpoint, and the whole security model here is loopback plus a token plus an Origin check — two of those three stop meaning anything the moment it's internet-reachable.

If port 8787 is already taken on your machine, change it in both halves or the plugin will dial a socket nothing is listening on:

  • %APPDATA%\vesktop-claude-bridge\config.jsonport (the HTTP mirror defaults to port + 1)

  • the plugin's Port setting in Equicord settings

Third eye

Toggle it from the chat-bar button (it opens a menu — mark, or watch) and it quietly buffers the channel you're in. Nothing reads that buffer until you ask, or until you next send Claude a message.

Capture costs nothing at all — no model, no tokens, no session — so it's fine to leave armed. Reading is the only part that spends anything, which is why the icon's burst only lights while a drain is happening: you can tell at a glance whether it's just watching or actually being read.

Set Third eye terms in the plugin settings to a comma-separated list of things you care about — a repo name, a mod name, a build number. Mentions and replies are caught automatically, but conversations about your work usually never name you, and that's the case the term list exists for.

Watches lapse after four hours and say so. Turning Discord off and on again keeps the watch but drops anything unread, because message bodies are never written to disk — and the next read says that happened, rather than handing back an empty buffer that looks like a quiet afternoon.

The buffer starts empty, so it never contains what led up to its first message. Every read names that starting point, so Claude can go back and read the run-up with discord_history when the conversation doesn't stand on its own. Nothing is fetched on your behalf: arming stays free, and the choice to spend anything on earlier context happens where you can see it.

DMs need two switches, and they are deliberately separate. Set Third eye watch DMs in the plugin settings to let the buffer fill from a DM at all, and denyDms: false in the sidecar config to let that content leave the renderer. Flip only the first and the buffer fills correctly and then the drain is refused at the boundary; flip only the second and the button still won't arm. The split is the point — the plugin setting decides what is collected, the sidecar decides what reaches a model — but it does mean a half-configured setup fails in two different-looking ways.

Draining a DM watch also goes through the confirmation prompt described under DM access, like any other DM read. Arming it doesn't — capture is free and never leaves the client, and the prompt belongs at the point where something is actually about to be read.

Inside a DM every message counts as notable, because a one-to-one has no ambient tier to sort against: nobody @-mentions you or uses the reply affordance, so the guild rules would find nothing to fire on and the notable-only hook below would stay silent while the buffer filled. Group DMs count the same way.

For the button alone to be enough, register the hook — otherwise you'd have to tell Claude the watch is running once per session:

{ "hooks": { "UserPromptSubmit": [ { "hooks": [ { "type": "command",
  "command": "node \"<repo>/scripts/third-eye-hook.mjs\"" } ] } ] } }

It runs on every message you send, times out after 2s, exits 0 on every failure path, and prints nothing when there's nothing to say.

DM access

Three gates stand between a private conversation and a model, across two processes. They aren't redundant: each refuses at a different point, and the earliest one is the strongest.

In Discord, under the plugin's settingsDM access, which is the one you'll actually use:

  • Ask me each time (the default) — a confirmation appears in Discord naming the conversation, offering Allow for 60 min, Always allow, or Deny. Nothing has been read at the moment you're asked. This gate sits in front of the RPC handler, so a refusal means the message bodies were never fetched, never serialised, and never put on the socket.

  • Always allow — no prompt. What the bridge did before this existed.

  • Off — every DM refused, the discord_dms listing included, and including anything on the permanent allowlist. This is the switch that overrides the others.

Temporary grants are held in memory and never written to disk, so Ctrl+R revokes every one of them. Always allow writes to IndexedDB — deliberately not to plugin settings, because Vencord's cloud settings sync uploads that blob wholesale, and a list of who you DM shouldn't leave the machine. Same reasoning that keeps the bridge token in localStorage. The permanently-allowed list renders in the plugin settings with a revoke button per entry: a permission you can't find again is a change of default, not a grant.

Declining puts that conversation on a five-minute cooldown. Without one, an agent that retries on refusal reopens the prompt immediately, and the only way out is to allow the thing you just refused.

In the sidecar configdenyDms and allowDms, under Scope and safety. Those refuse content that has already crossed the process boundary, which is exactly why they're the weaker pair.

When the prompt goes unanswered

The bridge holds the call open for 10 seconds. Past that it answers "a confirmation is on screen in Discord" and leaves the prompt up — clicking Allow afterwards still works, and the next attempt goes straight through.

That bound exists because the sidecar's rpcTimeoutMs defaults to 15s. A modal waiting on a human outlasts that easily, and the tool then reports timeout, which reads as "the bridge is broken" rather than "you haven't clicked yet" — and sends you debugging instead of answering. Lower rpcTimeoutMs below 10s and you get that timeout back.

The one thing that isn't gated

third_eye.state reports which channel is being watched, DM or not, with no prompt. It carries no message bodies, and it's open deliberately: the sidecar calls it precisely so it can refuse before draining the buffer, and gating the probe would break the ordering that stops a refused drain destroying the very thing it was refusing to show. Arming a DM watch already requires turning on Third eye watch DMs, which is off by default, so that channel has been opted into twice before this can say anything about it. The drain — where the content actually is — is gated normally.

Living with it

Once this is set up, Vesktop stops using the Vencord it downloads for itself and loads your Equicord build off disk instead. You still launch Vesktop the same way — nothing about the shortcut changes — but the chain underneath is now:

vesktop.exe
  └─ reads vencordDir from %APPDATA%\vesktop\state.json
       → D:\Equicord\dist\equibop
         ├─ require()s vencordDesktopMain.js   (main process)
         └─ injects  vencordDesktopPreload.js + vencordDesktopRenderer.js
              └─ Equicord reads its settings from %APPDATA%\Equicord\settings

Three things follow from that, and all three fail quietly rather than loudly:

The Equicord checkout is a runtime dependency, not a build artifact. Vesktop reads it on every launch, so it can't be cleaned up or moved. If those files go missing, Vesktop doesn't error — ensureVencordFiles() downloads stock Vencord release files into dist\equibop, leaving a half-Vencord-half-Equicord folder that behaves strangely rather than failing.

Never run a bare pnpm build in the Equicord checkout. The four vencordDesktop* files Vesktop loads are copies, not links. esbuild rewrites main.js and renderer.js and leaves the copies alone, so Vesktop silently keeps loading your previous build — no error anywhere, and the change you just made simply isn't there. Always rebuild through the script:

.\scripts\install-plugin.ps1 -EquicordPath D:\Equicord -Build

Run that same command after every git pull in Equicord. The plugin lives inside the Equicord tree at src/userplugins/vesktopClaudeBridge, so an update needs it re-copied and the aliases regenerated. This is the standing cost of userplugins; it's one command, but forgetting it looks like "my change didn't do anything" rather than like an error.

Getting back out

Clear Vencord Location in Vesktop settings and it reverts to the Vencord it manages itself, with the config in %APPDATA%\vesktop\settings. Both are left untouched by any of this, so that's a clean escape hatch if an Equicord update ever breaks something mid-conversation.

Note that the two configs are separate stores, not one shared one — changes you make under Equicord don't appear in the Vesktop-managed Vencord, and vice versa.

Tools

Tool

Use it when

discord_current_view

"read the logs" with no other detail — reads whatever channel is on screen

discord_marked

the user right-clicked → Mark for Claude, or hit the chat-bar button

discord_live

third eye is on and they're asking what's been happening while they worked

discord_fetch_attachment

a log/crash dump/diff is attached; downloads it and previews the head

discord_resolve_link

the user pasted a discord.com/channels/... link

discord_search

"find where someone mentioned X" — you know roughly what was said, not where

discord_history

paging back past what the client has cached

discord_reactors

who reacted, not how many — sign-up posts, "did I react", overlap between two messages

discord_guilds / discord_channels

turning "the modding server" into an id

discord_dms

"the DM with " — the only way to find one, since discord_channels can't see DMs

discord_status

anything above returned no_client

Everything is also on http://127.0.0.1:8788 with Authorization: Bearer <token>, returning the same text — useful from Bash when MCP isn't wired:

curl -s -H "Authorization: Bearer $(npm --prefix sidecar run --silent token)" http://127.0.0.1:8788/current-view

This is also the fastest way to debug the bridge itself, since it doesn't need MCP wired up or a session restart to pick up changes.

Tests

npm test

Boots the real sidecar with a fake plugin standing in for Discord, then checks the things that are invisible until you're debugging live: token and origin rejection, the no_client path, code fences surviving the formatter unindented, and the DM guard refusing — plus a second sidecar with denyDms: false to check it then serves, including a notable-only drain of a DM coming back non-empty, and a third with a non-empty allowDms to check that scoping refuses an unlisted DM without leaking the body it refused. Three processes rather than restarts, because the config is read once at boot.

It also pins that a refusal from the plugin's consent gate survives the trip: 403, message word for word, no body attached. The prompt itself can't be tested from here — that needs a renderer — but the refusal text is the entire user interface for that gate, and one of the two refusals is an instruction to retry rather than a verdict. Flattened into a generic forbidden, it would read as permanent and the click that fixes it would never get asked for.

The plugin half has no runtime tests — it needs a live client. Typecheck it against a real checkout instead:

npm run typecheck:plugin -- --equicord=D:/Equicord

Nothing else in this repo compiles that half. The sidecar's tsconfig covers sidecar/src only, and the plugin imports @webpack/common, which resolves nowhere but inside an Equicord tree. That config is also the stricter of the two, and the difference isn't academic — it's what caught an empty array literal widening to never[] on a change the sidecar build was perfectly happy with. The script copies the plugin in the way install-plugin.ps1 does, runs Equicord's own tsc, and reports only diagnostics naming our folder: Equicord's tree doesn't always typecheck clean on its own, so the exit code is not a verdict on us.

Scope and safety

A websocket on loopback has no same-origin protection — any page you visit can open one. Two things stop that page reading your Discord: it can't read the token (so it can't authenticate), and it can't forge an Origin header. Both are checked; either alone would do.

These two are the sidecar's half. The plugin has its own DM gate that refuses earlier and overrides both — see DM access.

Scope defaults, in %APPDATA%\vesktop-claude-bridge\config.json:

  • denyDms: true — "read my discord" shouldn't quietly mean all of it. Third eye has its own switch for the same question (see above); this one governs every tool, that one governs what the renderer will even buffer.

  • allowGuilds: [] — set guild ids to restrict further. Empty means all.

  • allowDms: [] — recipient ids whose DMs are readable, once denyDms is off. Empty means all of them, so this changes nothing until you fill it in. A group DM needs every member listed: reading it hands over what the others said too, and one allowlisted person in the room isn't consent from the other four.

  • pseudonymize: false — flip on to replace handles with user_a, user_b on the way out, so real handles never reach the model's context. Handy when the transcript is headed for a public repo.

The config is read once, at startup, so a change here needs the sidecar restarted before it means anything — and when Claude Code is the thing spawning it, that means a new session.

allowDms is matched against ids the plugin sends with the channel, so the two halves have to be in step: a plugin build older than this sidecar sends no recipient ids, and a non-empty allowDms then refuses every DM rather than waving them through. It says so when it does, and discord_status prints the DM scope it's actually running.

Why it's read-only

Automating a user account is against Discord's ToS. Reading through the client's own stores is very low-risk — it's data the client already has, and history fetches are the same calls the app makes when you scroll. Sending is where accounts actually get flagged, so there's no send path here at all.

If a write path is ever added, it should be draft-into-composer: the model writes the message, it appears in the box, you press enter. Safer, and honestly the behaviour you'd want anyway.

Roadmap

  • current_view, mark queue, history, link resolution, attachments

  • discord_search — guild search by text/author/mentions/attachment, with paging

  • Third eye — watch a channel in the background, read it back on demand

  • discord_reactors — expand a reaction count into the accounts behind it

  • discord_dms — DM and group-DM listing with recipients, plus an allowDms allowlist

  • Per-DM consent prompts in the client, with temporary and permanent grants

  • discord_threads — forum channel listing and thread reads

  • Mark ranges (shift-click two messages) rather than a fixed context window

  • Draft-into-composer write path

Licence

GPL-3.0-or-later. The plugin half links against Vencord/Equicord, which is GPL-3.0, so the whole repo follows.

Related MCP Connectors

Related MCP Servers

  • A
    license
    A
    quality
    D
    maintenance
    An MCP server that provides native Discord tools for Claude Code, enabling bidirectional communication with remote agents or humans via the Discord REST API. It allows users to send messages, read channel history, and manage reactions directly from their local environment.
    6
    15 npm
    3
    MIT
  • F
    license
    Not graded
    quality
    C
    maintenance
    An MCP server that enables Claude Desktop to interact with Discord through a dedicated bot. It allows users to list channels, read message history, and send messages directly from the AI interface.
    2
    -
  • F
    license
    A
    quality
    C
    maintenance
    MCP server that exposes Discord and Twitch/Chatty chat log query tools to Claude Code, enabling searching messages, channel stats, user messages, and events from chat logs.
    7
    -