9.4 KiB
Core bridge
The core bridge is the only surface between the Vesktop core and the external daemon. This document describes the protocol, the validation rules, and the hooks into upstream files.
Files
| File | Role | Status |
|---|---|---|
src/shared/puppetProtocol.ts |
Action names, error codes, limits, and shared types | Implemented |
src/renderer/puppet/actions.ts |
Validated action handlers and the core rate limit | Implemented |
src/renderer/puppet/events.ts |
Event normalization and push to main | Implemented |
src/renderer/puppet/index.ts |
Renderer initialization | Implemented |
src/main/puppetBridge.ts |
Unix socket server, request forwarding, event fan-out | Implemented |
Event subscription timing. The event module must wait for the flux dispatcher with
waitFor(filters.byProps("dispatch", "subscribe"), ...). A direct subscribe at module load can run
before Vencord registers the dispatcher. That mistake crashes the whole renderer bundle.
Transport
The core bridge uses a Unix socket. On Linux the path is $XDG_RUNTIME_DIR/vesktop-puppet.sock.
On Linux without XDG_RUNTIME_DIR, the path is /tmp/vesktop-puppet.sock. On Windows the path is
the named pipe \\.\pipe\vesktop-puppet.sock.
The protocol is newline-delimited JSON. Each request gets one response line. An event subscriber gets event lines until the connection closes.
Request:
{ "id": 1, "action": "message.send", "params": { "channelId": "123", "content": "hello" } }
Success response:
{ "id": 1, "ok": true, "result": { "channelId": "123", "sent": true } }
Error response:
{ "id": 1, "ok": false, "error": { "code": "CONTENT_EMPTY", "message": "content is empty" } }
Event line:
{ "type": "event", "event": { "type": "MESSAGE_CREATE", "channelId": "123" } }
Actions
| Action | Parameters | Result |
|---|---|---|
ping |
none | "pong" |
events.subscribe |
none | { "subscribed": true }. The socket becomes an event subscriber. |
state.get |
none | The current user, the guilds, and the channels. Cache-only. Includes user avatars and guild icons. |
voice.get |
optional userId, optional guildId |
The voice channel and its members, or channelId: null. Without guildId it searches all guilds. |
voice.overview |
guildId |
The occupied voice channels in the guild and their members |
voice.join |
channelId, optional mute (default true) |
Joins the voice channel. Sets self-mute first. |
voice.leave |
none | Leaves the current voice channel. |
voice.move |
channelId |
Moves the client to another voice channel. |
voice.devices |
none | The client's input and output audio devices. |
voice.setDevices |
optional inputDeviceId, optional outputDeviceId |
Sets the client devices. |
user.search |
query, optional guildId, optional limit |
Users from the client cache. Guild members when guildId is set. |
dm.list |
optional limit |
Cached DM and group DM channels with recipients, icons, and avatars. Cache-only. |
message.send |
channelId, content |
{ channelId, messageId, sent: true } |
message.read |
channelId, limit |
{ cached, messages }. Cache-only. Messages include the author avatar, attachment CDN links, editedAt, and replyTo. |
message.fetch |
channelId, limit, optional before or after |
{ fetched, messages }. Fetches from Discord. Same message shape. |
reaction.add |
channelId, messageId, emoji |
{ added: true } |
dm.send |
userId, content |
{ userId, channelId, sent: true } |
Events
The renderer normalizes client events and pushes them to subscribers.
| Event | Payload |
|---|---|
MESSAGE_CREATE |
Message ID, channel, guild, channel type, author, content, mentions, replyTo, editedAt, timestamp |
MESSAGE_UPDATE |
Message ID, channel, guild, author, content, editedAt |
MESSAGE_DELETE |
Message ID, channel, guild |
MESSAGE_DELETE_BULK |
Message IDs, channel, guild |
MESSAGE_REACTION_ADD |
Message ID, channel, guild, user, emoji |
MESSAGE_REACTION_REMOVE |
Same fields as add |
VOICE_STATE_UPDATE |
User, channel, guild, session, mute, deaf, self-mute, self-deaf |
VOICE_SPEAKING |
User, speaking flag, channel |
Discord's dispatcher only sends reliable speaking-start events. The daemon polls the core's
voice.speaking action (which reads ChannelRTCStore.getSpeakingParticipants) once a second and
synthesizes the missing start/stop events, feeding dispatcher hints into the same set. Discord
sends speaking as a bitfield number, so the core treats 0 as false and a missing flag as a
start pulse. A user who mutes or leaves also emits a state update that ends the speaking state in
the UI.
Validation rules
The core validates all input. The daemon cannot bypass these rules.
- A channel ID and a user ID must match the snowflake format. The format is 17 to 20 digits.
contentmust be a string. The content must not be empty after a trim.-contentmust not contain control characters.contentmust not be longer than 2000 characters.- The target channel must exist. The channel type must accept text. The allowed types are
GUILD_TEXT,DM,GROUP_DM,GUILD_ANNOUNCEMENT,ANNOUNCEMENT_THREAD,PUBLIC_THREAD, andPRIVATE_THREAD. limitmust be an integer from 1 to 100.beforeandaftermust be snowflakes. They are mutually exclusive.message.fetchuses them to walk history.voice.getaccepts snowflakes only. It reads the client cache. It never joins a call.voice.overviewrequires a guild snowflake. It reads the client cache.voice.joinandvoice.moverequire a channel snowflake and a voice channel type (2or13). The join sets self-mute when the action is available. The internal lookups (selectVoiceChannel,setSelfMute) were verified live on 2026-09-13.voice.leavetakes no parameters. It disconnects the client.voice.setDevicestakes short device ID strings. The device list comes fromnavigator.mediaDevices.enumerateDevices().user.searchtakes a query of 1 to 100 characters and a limit of 1 to 25. It reads the client cache only. An exact snowflake query returns that user when cached.dm.listtakes a limit of 1 to 50. It readsChannelStore.getSortedPrivateChannels()only.reaction.addaccepts a short emoji string without line breaks. It counts as an outbound action for the rate limit.- A request line must not be larger than 64 KiB.
Core rate limit
The core rate limit is a safety net. It does not replace the daemon pacing.
- The minimum interval between two outbound actions is 500 ms.
- The maximum is 30 outbound actions per minute.
- The limit applies to
message.sendanddm.send.
Error codes
| Code | Meaning |
|---|---|
INVALID_REQUEST |
The line is not valid JSON. |
PAYLOAD_TOO_LARGE |
The request line is larger than 64 KiB. |
UNKNOWN_ACTION |
The action name is not in the action list. |
CORE_UNAVAILABLE |
The bridge cannot reach the renderer. |
NOT_LOGGED_IN |
No user is logged in. |
INVALID_SNOWFLAKE |
The ID is not a snowflake. |
INVALID_PARAM |
A parameter has the wrong type or value. |
CONTENT_EMPTY |
The content is empty. |
CONTENT_TOO_LONG |
The content is longer than 2000 characters. |
CHANNEL_NOT_FOUND |
The channel is not in the client cache. |
NOT_SENDABLE |
The channel type cannot receive messages. |
SEND_FAILED |
Discord rejected the message. |
REACTION_FAILED |
Discord rejected the reaction. |
VOICE_FAILED |
Discord rejected the voice action. |
DM_UNAVAILABLE |
The client cannot create a DM channel. |
NOT_SUPPORTED |
The client does not offer the internal action. |
RATE_LIMITED |
The core rate limit rejected the action. |
INTERNAL |
An unexpected error occurred. The error message is included (for example a lazy module lookup before the renderer finished loading). |
A renderer command that is sent while the window is destroyed or crashed is rejected at once with
CORE_UNAVAILABLE, and a command that gets no answer times out after 15 seconds. The bridge socket
survives a renderer restart; the daemon retries background work such as device auto-connect. While
the renderer is gone, the bridge logs one warning instead of one stack per command, then stays
quiet until a command succeeds again.
Hooks into upstream files
The bridge uses a small number of hooks. Keep these changes minimal.
| File | Change |
|---|---|
src/main/main.ts |
Import the bridge. Call initPuppetBridge() after app.whenReady(). |
src/shared/IpcEvents.ts |
Add PUPPET_EVENT and IpcCommands.PUPPET_COMMAND. |
src/renderer/index.ts |
Import ./puppet. |
src/renderer/ipcCommands.ts |
Register PUPPET_COMMAND and call handlePuppetAction. |
src/preload/VesktopNative.ts |
Add puppet.emit(event) for event push. |
How to add an action
- Add the action name to
PuppetActioninsrc/shared/puppetProtocol.ts. - Add a validation case in
src/renderer/puppet/actions.ts. - Add the action to the known-action set in
src/main/puppetBridge.ts. - Add a tool in the daemon (
poc/src/control-bridge.ts). - Add a unit test for the validation path.
- Run
pnpm testTypes,pnpm lint, andpnpm build.
Why this shape
The shape gives three properties:
- The daemon cannot run arbitrary code in the client.
- A daemon bug cannot crash the client or send malformed data to Discord.
- The core patch stays small, so an upstream rebase is easy.