Files

10 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.processing optional noiseSuppression, echoCancellation, automaticGainControl, noiseCancellation, inputVolume Reads and sets Discord's microphone processing gates. Omitting the booleans turns every supported gate off; returns the previous and current state. Voice joins apply it automatically.
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 carry the author avatar, attachment metadata (id, filename, URL, content type, size, dimensions, duration), embeds (video, image, and thumbnail URLs plus their dimensions), sticker items, forwarded snapshots, a voice-message flag, editedAt, and replyTo.
message.fetch channelId, limit, optional before or after { fetched, transport, messages }. Fetches from Discord through the client's REST layer, so pages are full and contiguous; the client store (which keeps only the newest messages plus the channel's first) is the fallback. transport is rest or store. 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.
  • content must be a string. The content must not be empty after a trim.- content must not contain control characters.
  • content must 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, and PRIVATE_THREAD.
  • limit must be an integer from 1 to 100.
  • before and after must be snowflakes. They are mutually exclusive. message.fetch uses them to walk history.
  • voice.get accepts snowflakes only. It reads the client cache. It never joins a call.
  • voice.overview requires a guild snowflake. It reads the client cache.
  • voice.join and voice.move require a channel snowflake and a voice channel type (2 or 13). The join sets self-mute when the action is available. The internal lookups (selectVoiceChannel, setSelfMute) were verified live on 2026-09-13.
  • voice.leave takes no parameters. It disconnects the client.
  • voice.setDevices takes short device ID strings. The device list comes from navigator.mediaDevices.enumerateDevices().
  • user.search takes 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.list takes a limit of 1 to 50. It reads ChannelStore.getSortedPrivateChannels() only.
  • reaction.add accepts 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.send and dm.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

  1. Add the action name to PuppetAction in src/shared/puppetProtocol.ts.
  2. Add a validation case in src/renderer/puppet/actions.ts.
  3. Add the action to the known-action set in src/main/puppetBridge.ts.
  4. Add a tool in the daemon (poc/src/control-bridge.ts).
  5. Add a unit test for the validation path.
  6. Run pnpm testTypes, pnpm lint, and pnpm 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.