obs-websocket
Context: $ARGUMENTS
Remote-control a running OBS Studio instance via the bundled obs-websocket plugin (WebSocket RPC, protocol v5, default URL ws://localhost:4455). Scope: the WIRE PROTOCOL — auth handshake, Requests, Events, RequestBatch. For C++ plugins see obs-plugins; for Python/Lua inside OBS see obs-scripting; for installing/configuring OBS itself see obs-config.
MANDATORY verification
Before you claim a Request, Event, or OpCode exists, confirm it against the live protocol docs:
# If you have the obs-docs skill installed separately, use its search command \
search --query "<RequestOrEvent>" --page obs-websocket-protocol --limit 3
Every name/field/status in this skill was verified against obs-websocket-protocol (5.x) at authoring time. Do NOT invent Request names — the server closes the connection with UnknownRequestType (204).
Quick start
- Ping OBS (auth + GetVersion): → Step 1 → Step 2 → Step 3
- Switch scenes from a script: → Step 2 → Step 3 (
SetCurrentProgramScene) - Start/stop record or stream: → Step 3 (
StartRecord/StopRecord/ToggleStream) - Subscribe to live scene changes: → Step 4 (
eventSubscriptions=Scenes) - Fire N ops atomically: → Step 5 (OpCode 8 RequestBatch)
When to use
- Automate OBS from a CLI, bot, or web UI without writing a C++ plugin.
- Remote control OBS on another machine (producer → operator laptop over LAN).
- Drive scene switches from chat / MIDI / HTTP webhooks.
- Subscribe to
CurrentProgramSceneChanged,RecordStateChanged, etc., from a monitor dashboard.
If the user needs code that runs INSIDE OBS's own Python/Lua VM, use obs-scripting instead. If they need a native source / output / encoder, use obs-plugins.
Step 1 — Enable obs-websocket in OBS
OBS 28+ ships obs-websocket v5 BUNDLED. Older OBS needs the plugin installed manually from the obs-websocket GitHub repo.
- OBS menu: Tools → obs-websocket Settings.
- Tick Enable WebSocket server.
- Default port:
4455. - Password is enabled by default in newer releases — either click Show Connect Info to copy the current password, or disable auth (not recommended) or set your own.
- Click OK.
Test that the port is open:
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py check
Step 2 — Connect and authenticate
v5 handshake (NOT v4.x — protocol completely changed, v4 is EOL).
Client ─── HTTP Upgrade ──▶ Server
Client ◀── OpCode 0 Hello ─ Server (authentication.{challenge,salt} if password set)
Client ── OpCode 1 Identify ▶ Server (rpcVersion=1, authentication=<hash>, eventSubscriptions=<mask>)
Client ◀── OpCode 2 Identified ─ Server (connection ready)
Auth algorithm (exact — verified against protocol spec):
secret = base64( sha256( password + salt ) )
auth = base64( sha256( secret + challenge ) )
Both base64 calls use STANDARD base64 (not URL-safe). SHA-256 is invoked twice. If authentication is absent from Hello, NO password is set — omit the field in Identify.
Minimal Python (websocket-client):
import json, base64, hashlib, uuid, websocket
ws = websocket.create_connection("ws://localhost:4455")
hello = json.loads(ws.recv())
d = hello["d"]
auth_field = {}
if "authentication" in d:
c, s = d["authentication"]["challenge"], d["authentication"]["salt"]
secret = base64.b64encode(hashlib.sha256(("pw" + s).encode()).digest()).decode()
auth_field["authentication"] = base64.b64encode(hashlib.sha256((secret + c).encode()).digest()).decode()
ws.send(json.dumps({"op": 1, "d": {"rpcVersion": 1, **auth_field, "eventSubscriptions": 4}}))
identified = json.loads(ws.recv())
assert identified["op"] == 2
Step 3 — Send a Request (OpCode 6)
req = {
"op": 6,
"d": {
"requestType": "SetCurrentProgramScene",
"requestId": str(uuid.uuid4()),
"requestData": {"sceneName": "Starting Soon"}
}
}
ws.send(json.dumps(req))
resp = json.loads(ws.recv()) # op=7 RequestResponse
assert resp["d"]["requestStatus"]["result"] is True
requestId is client-generated and echoed verbatim in the response — use it to correlate async replies. If a Request has no fields, omit requestData entirely (or send {}).
Via the helper CLI (see scripts/wsctl.py). The CLI is fully automatic — URL and password are discovered from OBS's own plugin_config/obs-websocket/config.json. No --url / --password flags exist. Override via OBS_WEBSOCKET_URL / OBS_WEBSOCKET_PASSWORD env vars (e.g. remote host):
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py ping
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py scene-switch --scene "Starting Soon"
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py record --action toggle
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py request --type GetStats
Step 4 — Subscribe to Events (OpCode 5)
Events arrive unsolicited once you're Identified and subscribed. Pass eventSubscriptions as a bitmask in the Identify message (or later in Reidentify, OpCode 3).
Categories (verified):
| Bit | Name | Value |
|---|---|---|
| 0 | General | 1 |
| 1 | Config | 2 |
| 2 | Scenes | 4 |
| 3 | Inputs | 8 |
| 4 | Transitions | 16 |
| 5 | Filters | 32 |
| 6 | Outputs | 64 |
| 7 | SceneItems | 128 |
| 8 | MediaInputs | 256 |
| 9 | Vendors | 512 |
| 10 | Ui | 1024 |
| 11 | Canvases | 2048 |
| — | All | 4095 (OR of the above) |
| 16 | InputVolumeMeters (high-volume) | 65536 |
| 17 | InputActiveStateChanged (high-volume) | 131072 |
| 18 | InputShowStateChanged (high-volume) | 262144 |
| 19 | SceneItemTransformChanged (high-volume) | 524288 |
HIGH-VOLUME events (bits 16–19) are NOT in All. Opt in explicitly by ORing them in.
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py events --subscribe scenes,outputs
Event frame shape:
{"op": 5, "d": {"eventType": "CurrentProgramSceneChanged",
"eventIntent": 4,
"eventData": {"sceneName": "Main", "sceneUuid": "..."}}}
Step 5 — RequestBatch (OpCode 8)
Atomic or parallel fan-out of multiple Requests in one message.
{"op": 8, "d": {
"requestId": "batch-1",
"haltOnFailure": false,
"executionType": 0,
"requests": [
{"requestType": "SetCurrentProgramScene", "requestData": {"sceneName": "BRB"}},
{"requestType": "SetInputMute", "requestData": {"inputName": "Mic", "inputMuted": true}}
]
}}
executionType: -1 None, 0 SerialRealtime (default, ordered, gap for realtime tick), 1 SerialFrame (ordered, lockstepped to video frame), 2 Parallel (faster, NO ordering guarantee).
Helper:
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py batch --requests-file scene-reset.json
Gotchas
- v5 ≠ v4. The v4.x protocol is EOL and incompatible. Always target v5. Libraries like
obs-websocket-pymust be v1.0+. - Base64 flavor. Auth strings use STANDARD base64 (with
+/=), not URL-safe. - Double SHA-256. Inner hash =
password + salt, outer hash =base64(inner) + challenge. Allmask is 4095, not 2047. The enum includesVendors(512) andCanvases(2048) in addition to the bit-0..bit-10 categories. High-volume bits (16–19) are deliberately excluded — add them manually if you need volume meters, active/show state, or scene-item transform change events.- HighVolume events flood the socket.
InputVolumeMetersfires every 50 ms. Only subscribe if you'll consume. requestIdis yours. Client-generated, returned verbatim. Use it for request/response correlation in async clients.- Request fields optional by default. Many Requests (e.g.
GetVersion,GetStats,GetSceneList) take norequestData— omit the key entirely. sceneNamevssceneUuid. Most scene Requests accept EITHERsceneName(string) orsceneUuid. Prefer name unless scenes can be renamed at runtime.- SceneItemID is an integer scoped to a scene, not a GUID. Look it up with
GetSceneItemId(query by source name) or iterateGetSceneItemList. - Input-kind strings are platform-specific.
coreaudio_input_capture(macOS) vswasapi_input_capture(Windows) vspulse_input_capture(Linux). Always callGetInputKindListbefore creating an input programmatically. SetInputVolumeacceptsinputVolumeMul(0–20) ORinputVolumeDb(-100–26). Pass only one.- Cannot change encoder settings while an output is running.
OutputRunning (500)is the error code; stop the output first. CallVendorRequestis how plugins like StreamFX expose custom RPC — it's NOT an error, it's a passthrough channel.- Close codes 4xxx are non-negotiable.
4009 AuthenticationFailedmeans wrong password;4010 UnsupportedRpcVersionmeans you advertised anrpcVersionthe server doesn't speak;4007 NotIdentifiedmeans you sent a Request before Identified. - Parallel RequestBatch drops ordering.
executionType=2is fastest but do not rely on sequencing between requests. Use0if order matters. wsctl.pyhas NO--url/--passwordflags. URL (fromserver_port) and password (fromserver_passwordwhenauth_required=true) are read straight from the local OBS'splugin_config/obs-websocket/config.json. To point at a remote or non-default instance, setOBS_WEBSOCKET_URL/OBS_WEBSOCKET_PASSWORDin the environment. Config paths:~/Library/Application Support/obs-studio/...on macOS,%APPDATA%/obs-studio/...on Windows,~/.config/obs-studio/...on Linux.
Examples
Example 1: Hotkey-driven scene switcher
# bind MIDI note 36 to switch to scene "Wide"
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py scene-switch --scene "Wide"
Example 2: Toggle mute from voice-activity detector
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py mute --input "Mic/Aux" --action toggle
Example 3: Save a replay buffer highlight
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py replay-buffer --action save
Example 4: Stream live events to jq
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py events --subscribe scenes,outputs \
| jq 'select(.d.eventType=="CurrentProgramSceneChanged")'
Example 5: Escape hatch — any Request
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py request \
--type GetSceneItemTransform \
--data '{"sceneName":"Main","sceneItemId":42}'
Example 6: Remote host (override via env)
OBS_WEBSOCKET_URL=ws://10.0.0.42:4455 \
OBS_WEBSOCKET_PASSWORD=hunter2 \
uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py ping
Reference docs
references/protocol.md— OpCodes, RequestStatus (100–703), close codes (4000–4012), EventSubscriptions bitmask, RequestBatch executionType, auth pseudocode, commonly-used Requests/Events grouped by category, client snippets for Python/Node/Go/browser, handshake diagram, keepalive strategy.
Troubleshooting
Error: connection refused on port 4455
Cause: obs-websocket server disabled, OBS not running, or bound to a different interface.
Solution: Tools → obs-websocket Settings → Enable WebSocket server. Verify port with uv run ${CLAUDE_SKILL_DIR}/scripts/wsctl.py check.
Close code 4009 AuthenticationFailed
Cause: Discovered password doesn't match the running server — usually because the user rotated the password from Tools → obs-websocket Settings but the file on disk hasn't been reloaded.
Solution: The helper reads plugin_config/obs-websocket/config.json at call time; if OBS hasn't flushed the config yet, restart OBS or override once with OBS_WEBSOCKET_PASSWORD=.... For custom clients: verify STANDARD base64 and hash TWICE (inner: pw+salt, outer: b64secret+challenge).
Close code 4010 UnsupportedRpcVersion
Cause: You advertised rpcVersion the server doesn't speak — usually a v4 client hitting a v5 server.
Solution: Use rpcVersion: 1 and a v5-compatible client library.
Close code 4007 NotIdentified
Cause: Sent a Request (OpCode 6) before receiving Identified (OpCode 2). Solution: Wait for the OpCode 2 message. Never pipeline Requests ahead of identification.
RequestStatus 204 UnknownRequestType
Cause: Typo or v4-era name. Common trap: SetCurrentScene (v4) vs SetCurrentProgramScene (v5).
Solution: Verify via the obs-docs search command before sending.
RequestStatus 600 ResourceNotFound
Cause: Bad sceneName, inputName, or sceneItemId.
Solution: Enumerate with GetSceneList, GetInputList, or GetSceneItemList first.
RequestStatus 500 OutputRunning / 501 OutputNotRunning
Cause: Tried to mutate encoder/output state while output was in the wrong state.
Solution: Stop the output (StopStream/StopRecord), reconfigure, then restart.
Event flood / CPU spike
Cause: Subscribed to high-volume events (InputVolumeMeters fires every 50 ms).
Solution: Re-Identify (OpCode 3) with a narrower eventSubscriptions mask.