WebSocket RPC protocol
Authentication, version 1 JSON messages, commands, errors, and lifecycle events.
Connect to ws://<host>:<port>/v1/ws or wss://.../v1/ws. The server accepts standard WebSocket
URLs. The @remedia/client convenience schemes remedia:// and remedias:// map to those URLs and
default to /v1/ws. Authenticate and identify the bot in the WebSocket HTTP upgrade:
Authorization: Basic base64(username:password)
X-Remedia-Bot-Id: 100000000000000001The bot header must contain a positive decimal Discord snowflake that fits u64. The server binds
it to the client session created or resumed on that socket. Reconnecting to resume a session must
use the same bot ID. This value is supplied at runtime, not configured on the server, so separate
bots can use the same server account. Credentials in query parameters are not supported.
Authentication and bot identity validation happen before allocating client, player, or voice
resources. Invalid Basic credentials receive HTTP 401. A missing or malformed bot header receives
HTTP 400.
Connect by hand
@remedia/client does all of this for you. A hand-written client opens a WebSocket with the
handshake headers above, then creates a client session. Install ws for this standalone example (npm install ws) and set the password and bot user ID in its environment:
import { WebSocket } from 'ws';
const username = process.env.REMEDIA_RPC_USERNAME ?? 'media-bot';
const password = process.env.REMEDIA_RPC_PASSWORD;
const botUserId = process.env.DISCORD_BOT_USER_ID;
if (!password || !botUserId) throw new Error('Set REMEDIA_RPC_PASSWORD and DISCORD_BOT_USER_ID');
const socket = new WebSocket('ws://127.0.0.1:8787/v1/ws', {
headers: {
Authorization: `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`,
'X-Remedia-Bot-Id': botUserId,
},
});
socket.on('open', () => {
socket.send(
JSON.stringify({ version: 1, id: 'create-client-1', command: 'client.create', params: {} }),
);
});The response carries an opaque sessionId and resumeCredential. The client session owns its guild
sessions (guild.create), and each guild session owns one player and an optional Discord voice
transport. After an unexpected disconnect, client.resume reclaims the session within the configured
resumption window. Events are not replayed while detached, and a client that falls behind receives
client.events_lost. To play audio, send Gateway voice credentials to voice.connect, then
player.play:
{
"version": 1,
"id": "play-1",
"command": "player.play",
"params": {
"guildId": "200000000000000001",
"source": { "type": "url", "url": "https://media.example.com/audio.ogg" }
}
}Playback without a voice transport uses a null output for validation and does not produce audible
sound. Raw FFmpeg graphs (player.filter.set) are trusted configuration. Do not expose them to
untrusted users.
Request and response
Every request is one text WebSocket frame containing a strict JSON object. Unknown top-level and command parameter fields are rejected.
{ "version": 1, "id": "request-1", "command": "node.info", "params": {} }Responses retain the request ID:
{
"version": 1,
"id": "request-1",
"ok": true,
"result": { "nodeId": "...", "version": "0.1.0", "protocolVersion": 1 }
}{
"version": 1,
"id": "request-2",
"ok": false,
"error": { "code": "invalid_state", "message": "..." }
}The server accepts protocol version 1, requires a 1–128 byte request ID and command name, and
requires params to be an object. Binary frames are rejected. WebSocket frame and message sizes,
in-flight requests, per-connection request rate, and request duration are bounded by configuration.
Error codes
| Code | Meaning |
|---|---|
invalid_request | Malformed JSON/protocol message, unknown command, invalid parameters, or denied media source |
authentication_failed | Handshake authentication failed (HTTP 401) |
unknown_session | Session or guild is missing or not owned by this connection |
invalid_state | Operation conflicts with the current session/player state |
resource_exhausted | Configured connection, session, player, request, or event capacity was reached |
request_timeout | Operation did not produce a response before its configured deadline. Started cleanup/native work may continue safely |
unsupported | Requested media operation, such as seeking, is unsupported for this source |
playback_failed | Media open, decoder, resampler, filter, encoder, or playback worker failed |
cancelled | A pending plugin operation was cancelled by stop, replacement, staging changes, plugin.cancel, disconnect, session close, or shutdown |
stale_generation | A staging request or result belongs to a source generation that is no longer current |
plugin_* | Plugin-host failures: see Playing audio through plugins and forwarding |
invalid_bot_user_id | Missing or malformed bot identity during the HTTP upgrade (HTTP 400) |
media_failed | Disposable media probing failed to open or inspect the source |
transport_failed | Discord voice connect, disconnect, or resource cleanup failed |
Messages are safe summaries and do not include raw media URLs, passwords, or voice tokens. Inspect server diagnostics for operational context.
Lifecycle events
Events are separate text frames and do not have request IDs:
{
"version": 1,
"event": "player.completed",
"sessionId": "...",
"sequence": 12,
"data": { "guildId": "200000000000000001", "generation": 3 }
}Implemented event names include guild.created, guild.destroyed, player.started,
player.paused, player.resumed, player.seeked, player.stopped, player.filter_changed,
player.volume_changed, player.completed, player.failed, voice.state_changed, voice.failed,
and client.events_lost.
A successful player.play response and every player.state snapshot include the same monotonically
increasing source generation as that playback's player.started, player.completed, and
player.failed events. Consumers can bind waiters to a generation so a later source cannot complete
an older waiter. The source generation is independent of transport replacement generations.
Events are bounded and are not replayed while the client is detached. A slow consumer can receive
client.events_lost with a dropped-event count. client.resume, guild.list, guild.get, and
player.state provide authoritative snapshots after reconnect.
Session resumption
client.create returns an opaque sessionId and high-entropy resumeCredential. Store the
credential securely. On unexpected socket loss, the server keeps resources for
sessionResumptionTimeoutSeconds. The same authenticated username may call client.resume with
both values while detached and before expiry. The credential stays stable until the session closes.
This allows a client to retry if a resume response was lost. A concurrent socket cannot control an
already attached session.
client.close releases resources immediately. Expiry and graceful shutdown do the same. Active
sessions are in-memory only and are lost when the server process restarts.
Server build identity
node.info and node.stats report version from the release metadata embedded at build time
(including prerelease suffixes), and commit with the Git SHA or unknown for a source archive
without supplied metadata. These match the CLI build identity.