Remote control: Stream Deck, Companion and OBS-compatible websocket controllers
stream-concern can run a small remote control server. Stream Deck plugins and other controllers can then switch scenes, start and stop recording and streaming, drive Studio mode, and move and mute the master and mixer faders. The server speaks the OBS-compatible websocket protocol (obs-websocket 5).
Every command runs through the same code as the matching button in the app (the same actions as keyboard shortcuts and MIDI), with the same checks and status-bar messages. Changes you make in the app (a scene click, a fader drag, starting a recording) are sent back to connected controllers, so key lights and dial displays stay in step.
The same server can also let an AI agent control the app; see AI agents (MCP).
Turning it on
- Open Settings ▸ Remote control.
- Tick Enable remote control server. A random password is generated the first time (see Password).
- Leave Port at 4455 (the number every client fills in for you) unless another app on this computer already runs a WebSocket server on it; then choose another port, such as 4456, and use that number in the client.
- Press Apply. The Status line reads Listening on all interfaces (0.0.0.0:4455) (or the address you chose under Listen on) and the line below counts connected clients.
If the port is taken (usually by another streaming app), the Status line says so in red. The app never picks another port or address by itself, because your controllers are configured with them: quit the other program and press Retry (or Apply), or choose a different port and press Apply.
The server is off until you enable it, and nothing listens on the network while it is off.
Password
Require password is on by default. The password is generated from the system’s secure random source (24 letters and digits). Use Show to read it, Copy to put it on the clipboard for pasting into the client, and Regenerate for a new one (every client then needs the new password). You can also type your own.
The password is saved in your profile (profile.toml, next to your stream
key) so that clients keep working after a restart. It is never written to the
log. The check is the protocol’s standard challenge: the password itself
never crosses the network.
Listen on
Which of this computer’s network addresses the server accepts connections on:
- All interfaces (0.0.0.0), the default: programs on this computer (127.0.0.1) and on your network (this computer’s address) can connect: a Stream Deck plugged in here, a phone or tablet deck app, Companion on another computer.
- This computer only (127.0.0.1): only programs on this computer, which is all a Stream Deck plugged into this computer needs.
- One address, listed with its network interface (
192.168.1.20 (en0), a VPN address): only clients that reach that address. The list is read when Settings opens. An address you chose earlier that this computer no longer has (the laptop moved to another network) stays listed as “192.168.1.20 (not found)”; the server then does not start, the Status line says why, and nothing else is listened on instead: pick another address, or All interfaces.
Listening on every interface is the default because every connection needs the password (Require password is on, and a random one is generated when you enable the server), and the Status line always shows the address in use. Earlier versions listened on 127.0.0.1 unless Allow LAN connections was on; a profile from them keeps what it had (Allow LAN on becomes All interfaces, off becomes This computer only). Keep Require password on unless the server listens on this computer only.
Stream Deck
stream-concern now has its own Stream Deck plugin with the app’s icons and live key states; see Stream Deck plugin for what it does and how to install it.
The Stream Deck section of Settings ▸ Remote control (between the server and MCP) says whether the plugin is installed (on macOS and Windows), offers Install the plugin when this build ships the plugin file, links to the docs, and gives the Host and Port to enter in the plugin: the address the server listens on (127.0.0.1 when it listens on all interfaces, plus this computer’s network address for a deck elsewhere) and the port. Elgato’s Stream Deck app does not run on Linux; there the section points at Bitfocus Companion.
Third-party Stream Deck plugins that speak the same protocol also work: in the plugin’s connection settings enter the host and port the Stream Deck section shows (127.0.0.1 and 4455 by default) and the password from Settings ▸ Remote control. Their scene, record, stream, Studio mode and mute actions use the requests listed below; actions for features stream-concern does not have (see What is supported) do nothing. Plugins that talk to a companion plugin inside another app instead of over the network cannot connect.
If an action misbehaves, the app’s log (see Getting started) records connections and failed password attempts.
The master output appears as an input named Master, and the microphone strip under its own name (it is also the “Mic/Aux” special input), so mute and volume actions can target them.
Start or stop everything at once
The protocol has no “start everything” command. Make a Stream Deck Multi Action with the plugin’s Start Stream (or Toggle stream) and Start Record (or Toggle record) actions. The app’s own “Start / stop all outputs” (⌘⇧A) also starts additional outputs from the Outputs panel; the Stream Deck buttons control the primary stream and recording.
Bitfocus Companion and Touch Portal
Both let you add a connection for this protocol with a host, port and password; enter 127.0.0.1 on this computer (or, from another computer, this computer’s address while the server listens on all interfaces or on that address), the port and the password.
Companion does not connect yet. Its module for this protocol uses the binary MessagePack format, which this build does not speak (JSON only), so the connection is refused during the handshake. This is tracked as a follow-up.
Touch Portal has not been tested.
Loupedeck / Logi plugins read another app’s settings file instead of connecting over the network, so they do not work with stream-concern.
What is supported
| Area | Requests | What happens |
|---|---|---|
| Recording | GetRecordStatus, StartRecord, StopRecord, ToggleRecord |
The Controls panel’s recording button. |
| Recording pause | PauseRecord, ResumeRecord, ToggleRecordPause |
Pause / Resume on the recording’s row in the Outputs list (⌘⇧P on macOS). |
| Streaming | GetStreamStatus, StartStream, StopStream, ToggleStream |
The Controls panel’s streaming button. |
| Outputs | GetOutputList, GetOutputStatus, StartOutput, StopOutput, ToggleOutput |
An Outputs panel row’s Start / Stop button. |
| Virtual camera | GetVirtualCamStatus, StartVirtualCam, StopVirtualCam, ToggleVirtualCam |
Start / Stop on the Outputs panel’s Virtual camera row. |
| Replay buffer | GetReplayBufferStatus, StartReplayBuffer, StopReplayBuffer, ToggleReplayBuffer, SaveReplayBuffer, GetLastReplayBufferReplay |
Start / Stop and Save replay on the Outputs panel’s Replay buffer row. SaveReplayBuffer answers 501 while the buffer is off; GetLastReplayBufferReplay answers 604 until a replay was saved this session. |
| Scenes | GetSceneList, GetCurrentProgramScene, SetCurrentProgramScene |
The scene’s ▶ button (in Studio mode: select as Preview, then Publish). |
| Studio mode | GetStudioModeEnabled, SetStudioModeEnabled, GetCurrentPreviewScene, SetCurrentPreviewScene, TriggerStudioModeTransition |
The Studio mode toggle above the preview, clicking a scene’s name, and Publish. |
| Audio | GetInputList, GetSpecialInputs, GetInputVolume, SetInputVolume, GetInputMute, SetInputMute, ToggleInputMute |
The mixer faders and Mute buttons. Master has no mute. |
| Monitoring | GetInputAudioMonitorType, SetInputAudioMonitorType |
A strip’s Routing ▸ Monitor pull-down (OBS_MONITORING_TYPE_NONE = Off, …_MONITOR_ONLY, …_MONITOR_AND_OUTPUT). Master has none. The monitor output itself is under Monitoring. |
| Hotkeys | GetHotkeyList, TriggerHotkeyByName |
Runs the action a hotkey runs, whether or not a key is bound to it. Names are the action names MIDI bindings use (toggle-recording, next-scene, …) and the protocol’s standard names for the same actions (OBSBasic.StartStreaming, OBSBasic.StopRecording, …). |
| General | GetVersion, GetStats, request batches (with Sleep) |
GetStats reports message counts only. |
Controllers are told about scene switches, the scene list changing, Studio
mode and Preview changes, recording and streaming starting and stopping,
the recording pausing and resuming, the virtual camera starting and
stopping, mixer inputs being added, removed or renamed, fader moves and
mutes, monitoring mode changes (InputAudioMonitorTypeChanged), and the
server shutting down. Other outputs send no events (the
protocol defines none for them); controllers ask GetOutputList again.
A few details:
SetCurrentProgramScenein Studio mode goes through Preview and a Publish, like the scene’s ▶ button in the app: the scene becomes the Preview scene and is then taken to Program with its transition. The protocol’s reference implementation instead switches Program directly and leaves Preview alone. (The app has no “switch Program but keep Preview” path in Studio mode.)- In a request batch, a request that changes something (a scene switch, a
start or stop, a fader) waits up to 100 ms for the app to apply it before
the next request in the batch runs, so a later
Get…in the same batch sees the change. If the app is busy for longer, the next request runs anyway and may still see the old state. - A connection must identify (answer the handshake) within 10 seconds or it is closed, so stalled connections cannot use up the server’s 64 connection slots. One computer (IP address) may hold at most 8 connections at a time; more are refused.
- A start or stop answers as soon as the command is queued; the new state follows as an event a moment later. Starting something that is already running, or stopping something that is not, is refused.
- Volumes use the mixer’s range: requests below −60 dB set the fader’s bottom stop (−∞) and requests above +12 dB set its top.
- Scenes and inputs have stable IDs across restarts, so controllers that remember them (Companion) keep working. Renaming a scene keeps its ID.
- Outputs are named as in the Outputs panel. When two outputs share a name,
the later one in the panel is called
Name (2)(then(3), …), and an unnamed output isOutput <number>; renaming or reordering outputs can therefore change which output a name means. Output kinds use the protocol’s usual names where they exist (rtmp_outputfor a stream,ffmpeg_muxerfor a recording,virtualcam_output), andndi_output,syphon_output,spout_outputorpipewire_outputfor the senders. Senders report a size of 0 × 0 (they publish the program canvas as it is).GetOutputStatusreports whether the output runs and for how long; its byte and frame counts are 0. GetRecordStatusreports the recording file’s own duration (which stops while paused) and size. Pausing or resuming when the recording is not running, pausing when paused, or resuming when not paused is refused with the protocol’s status codes.- The virtual camera requests control the profile’s Virtual camera output (the first one, should there be several). Without one, the status reads “not active” and start, stop and toggle are refused as “not available” (code 604), as the protocol specifies when there is no virtual camera.
- Anything else a client asks for (sources, filters, transitions, profiles, screenshots, the replay buffer, audio meters) is answered “not supported” and the connection stays open; the client’s action does nothing.
Scene automation
Scene automation (see Scenes) is reachable
through obs-websocket’s CallVendorRequest with vendorName
stream-concern:
requestType |
requestData |
Response / effect |
|---|---|---|
GetSceneAutomation |
none | running, held (waiting for a Studio Publish), mode ("sequence" or "random"), dwellSeconds, mainSceneName, includeMain, returnToMain (the main scene shows between every other scene), mainDwellSeconds (how long it stays then), overlay (the other scenes pop up over the main scene), popupSceneName (the scene popped up now, or null), transition (null when each scene uses its own; otherwise the transition object below), sceneNames (the rotation, in the order you arranged it), nextSceneName, secondsRemaining. |
SetSceneAutomation |
any of running (true starts, false stops), mode, dwellSeconds (0.5 to 3600), overlay (true or false), transition ("own" for each scene’s own transition, or a transition object) |
Queued like the window’s controls. At least one field is required. |
A transition object has kind: "cut", "crossfade",
"fade_through_black", "appear_disappear", "slide", "push", "wipe"
or "zoom"; durationMs (0 to 10000); easing ("linear" or
"ease_in_out"); and the kind’s own choice: direction ("left",
"right", "up" or "down", the way the picture travels) for slide, push
and wipe, zoom ("in" or "out") for zoom, motion ("fade", "zoom",
"up", "down", "left" or "right") for appear_disappear. In a
response it also has name, the label the app shows (“Slide left 500 ms”).
In a request only kind is required: a field left out keeps the current
automation transition’s value where the kind has one, as the app’s picker
does (a Cut becomes 500 ms, ease in-out). A choice the kind does not take
(a direction for a crossfade) is refused (401).
Whenever any of those values changes (including each automated switch), the
server sends a VendorEvent with vendorName stream-concern, eventType
SceneAutomationChanged and the same fields as GetSceneAutomation, to
clients subscribed to vendor events (bit 512, part of the default
subscription). The main scene and the ticked scenes are chosen in the app
only. Another vendorName is refused as “resource not found” (600).
Monitoring
The mixer’s Monitor section (the headphone output) and each input’s
monitoring are reachable through CallVendorRequest with vendorName
stream-concern:
requestType |
requestData |
Response / effect |
|---|---|---|
GetMonitor |
none | enabled (Enable monitor is on and the device is open), deviceName (null while off), levelDb (the Monitor gain slider), muted, and inputs: one {inputName, inputUuid, monitored, monitorType} per mixer input (Master has none). monitored is true unless the input’s Routing ▸ Monitor is Off. |
SetMonitorLevel |
levelDb (−100 to 26) |
The Monitor gain slider; values outside −60 to +12 dB go to its ends. |
SetMonitorMute |
muted |
The Mute monitor tick box. |
ToggleMonitorMute |
none | Flips it; the response has the new muted. |
SetInputMonitor |
inputName or inputUuid, and monitored |
false sets the input’s Routing ▸ Monitor to Off; true sets an Off input to “Monitor and output” and leaves “Monitor only” alone. Master is refused (703). |
SetMonitorLevel, SetMonitorMute and ToggleMonitorMute are refused with
code 604 while the monitor is off, as the app greys those controls out; turn
it on in the mixer first. The device is chosen in the app only.
Whenever any GetMonitor value changes (the level, mute, on/off, device, an
input’s monitoring, or the input list), the server sends a VendorEvent with
eventType MonitorChanged and the same fields as GetMonitor, after the
input events of the same change.
Components page
While the server runs, the same port also serves a web page in any browser: http://127.0.0.1:4455/components on this computer, or this computer’s address while the server listens on it (for example a Linux machine with no screen, reached from a laptop). It lists the components stream-concern can use but does not include, in the same words as the Settings pages:
- OpenH264 (
/components/openh264): Cisco’s notice, the state line, Download and Remove (they do exactly what the buttons in Settings ▸ OpenH264 do; the page reloads itself every 2 seconds while a download runs), and the licence texts. - NDI runtime (
/components/ndi): whether it is installed, the ndi.video link and the install steps for this platform. The runtime is Vizrt’s to distribute, so the page explains how to install it rather than downloading it.
The page asks for the remote control password: sign in with any user name and that password (the browser’s own sign-in prompt, HTTP Basic). It is never put in the address. With Require password off the page is refused and says to turn it on, because without it anyone who can reach the port could download code into the app. Download and Remove only work from the page itself, never from a link on another website. Plain HTTP is not encrypted: on a LAN the password crosses the network readable by anyone watching it, unlike the controllers’ challenge, so use the page on trusted networks and regenerate the password if in doubt.
AI agents (MCP)
The same server can let an AI agent control the app: Claude Code, Claude Desktop or any other client of the Model Context Protocol (MCP). The agent sees the app’s actions as tools (switch scene, Studio mode and Publish, start and stop streaming, recording and each output, faders, mutes and monitoring, scene automation), reads the app’s state as resources (scenes, outputs, the mixer, scene automation and the recent log lines), and offers a few prompts (go live, end the stream, a health check, set up a scene rotation). Every tool runs through the same code as the matching button, so an agent can do nothing the app’s own controls would not.
Turning it on
- In Settings ▸ Remote control, turn on the remote control server and keep Require password on (MCP refuses every agent without it).
- Under MCP server for AI agents, tick Enable MCP server, choose which tool groups agents may use (Scenes, Studio mode, Outputs, Audio mixer, Scene automation) and when to Ask before live actions (below).
- Optionally give MCP a port of its own (MCP port; empty means the
remote control port).
/mcpthen answers only there, on the same address as the server, and the remote control port no longer serves it. - Press Apply. The MCP line reads Serving at
http://127.0.0.1:4455/mcp (listening on all interfaces): the address
an agent on this computer uses, with the real port. When the server
listens on one address only, the line shows that address. If MCP’s own
port is taken, the line says why and the remote control server keeps
running without
/mcp.
Connecting an agent
Claude Code, through the app’s own relay (it reads the address, the port or MCP’s own port, and the password from your profile; the app must be running):
claude mcp add stream-concern -- stream-concern --mcp
stream-concern --mcp is the stream-concern program (the egui build)
started with one flag: it opens no window and relays the agent’s messages to
the running app. Use its full path if it is not on your PATH. To point it
elsewhere, set SC_MCP_URL (for example http://127.0.0.1:4456/mcp) and
SC_MCP_PASSWORD.
Claude Desktop (its claude_desktop_config.json):
{
"mcpServers": {
"stream-concern": { "command": "/path/to/stream-concern", "args": ["--mcp"] }
}
}
Any client that speaks MCP over HTTP can connect directly to
the URL the MCP line shows (http://127.0.0.1:4455/mcp by default) with the
password as
Authorization: Bearer <password>, for example:
claude mcp add --transport http stream-concern http://127.0.0.1:4455/mcp \
--header "Authorization: Bearer <password>"
Asking before live actions
Going live, stopping a stream, recording or output, and a Studio mode
Publish change what your audience sees. With Ask before live actions
set to Always (the default), such a tool does nothing until the agent
calls it again with confirm: true, which the agent is told to pass only
after asking you. While live asks only while something records or
streams; Never turns the question off. Switching scenes, faders and
mutes never ask.
What an agent never gets
No tool takes or returns a stream key, and the state an agent reads has no
keys, passwords or ingest URLs in it; the log lines it reads pass the
log’s stream-key scrubber (anything written key=… and live_…/sk_…
tokens are replaced), and the app never logs a key beyond its first four
characters. Every tool call is listed under the MCP line in Settings ▸
Remote control (the newest first) and written to the app’s log.
Security notes
- The server is off by default. Once on, it listens on every network interface unless you choose This computer only or one address, and it requires a password unless you turn that off. The Status line always shows where it listens.
- The components page uses the same password over plain HTTP Basic, so the password itself crosses the network when you sign in from another computer.
- AI agents send the password with every request over
plain HTTP too (the
--mcprelay only within this computer: 127.0.0.1, or the one address the server listens on). A web page cannot drive/mcp: any request a browser marks with its origin is refused. - Anyone with the password can start and stop your stream and recordings and switch scenes. Regenerate the password if it leaks.
- The connection is not encrypted (the protocol has no TLS). On a LAN, the password is protected by the challenge, but commands are sent in the clear.
- Failed password attempts (controllers and the components page alike) are logged with the client’s address, at most one line every 10 seconds (the next line says how many were not shown).
Where the settings are saved
In your profile under [remote], written only once you change something:
[remote]
version = 2
enabled = true
port = 4455
bind = "any" # "any", "loopback" or one of this computer's addresses
require_password = true
password = "…"
[remote.mcp]
enabled = true
port = 4456 # optional: MCP's own port (absent: the remote control port)
confirm = "always" # "always", "while_live" or "never"
[remote.mcp.groups] # the tool groups agents may use (all on by default)
scenes = true
studio = true
outputs = true
audio = true
automation = true
A profile from a newer version loads without error; keys this version does
not know are ignored. A version 1 section’s allow_lan = true reads as
bind = "any" and allow_lan = false as bind = "loopback"; a bind this
version cannot read means this computer only.