Drive OBS Studio from a keyboard-first terminal dashboard β or script it from the CLI. A single Rust binary: a daemon that owns the OBS connection, a live TUI, and a stable proxy CLI.
Website Β· obs.worxbend.com Β· Demo Β· Install Β· Quick Start Β· TUI Β· Themes Β· CLI Β· IPC Protocol
| ποΈ Real-time TUI dashboard | Scenes, audio matrix with dB meters, profiles, collections, live telemetry, and a streaming log feed β all in one keyboard-driven view. |
| β‘ Snappy, non-blocking input | Volume, mute, and scene switches apply optimistically with debounced writes, so the UI never stalls on a round-trip. |
| π§© Daemon-first architecture | One process owns the OBS WebSocket. The TUI and CLI are thin IPC clients over a local Unix socket. |
| π¨ 29 built-in themes | btop-style live theme picker with whole-UI preview, plus fully custom palettes β down to a TTY-safe mono mode. |
| π Scene profiles | Name a set of scenes to hide β utility and nested scenes stop cluttering every list. Switch sets from the TUI (<Space>P), the palette, or the CLI. |
| π€ Scriptable CLI | Every action has a proxy command with a stable --json envelope and documented exit codes β perfect for hotkeys and automation. |
| π Secret-safe by design | Passwords come from env vars, never plaintext config; logs and error messages are run through a redaction boundary. |
| π Localizable | Ships English + Ukrainian, with drop-in runtime locale files β no recompile required. |
| π¦ Single static binary | Pure Rust + Ratatui. curl | sh install, or a systemd --user service. No runtime dependencies. |
A real-time, themeable command center with animated gradient chrome, rounded/heavy focus borders, Unicode symbols, and responsive layouts. The advanced interface is enabled by default; set ui.advanced_ui: false for a simplified ASCII-safe TTY mode, or ui.show_icons: false to hide icons while keeping the other advanced elements.
- π¬ Scenes panel (
s) β navigate withj/kor arrows,Enterto switch; the newly active scene briefly flashes. Scenes marked hidden are left out of the list, and<Space>Popens the scene-profile editor: a modal that lists every scene (hidden ones dimmed),thides or reveals the one under the cursor,nnames the set, andEntersaves it to the config as a named scene profile you can switch between βPcycles through them without opening anything, and the panel title names the one in effect - π Audio matrix (
a) β OBS's Audio Mixer in the terminal: one bordered vertical channel strip per input, each with a fader, a green/yellow/red dB meter, and its own dB scale.β/β(orh/l) picks a strip,β/β(ork/j) nudges its volume Β±5%,mmutes - ποΈ Profiles panel (
p) βEnterto switch OBS profiles - π Collections panel (
c) βEnterto switch OBS scene collections - β¨οΈ Vim / AstroNvim keymap β
j/k,gg/G,Ctrl-D/Ctrl-U, count prefixes (12j),Ctrl-hjklwindow moves,Tabto cycle panes, and a<Space>leader with a which-key popup - π±οΈ Mouse navigation β click a row to focus and select it, click again to activate, wheel to scroll a panel or the log history, right-click to cancel
- π¦ Broadcast status pane β pinned to the top-right corner, spelling the current state in oversized block letters:
IDLEwhen nothing is running,LIVEandREC(side by side when both) with elapsed durations as they start. Each state animates its own distinct spinner, so the state is readable from the animation alone - π‘ Telemetry deck β active scene, FPS, stream bitrate with a sparkline, and braille meters for CPU and memory that mark the session peak alongside the live value
- πͺ΅ Logs panel β severity glyphs, colored targets/timestamps, and semantic highlighting for actions, status/error keywords, commands, numbers, and live OBS scene/profile/collection/input names
- β‘ Stats panel β appears beside the logs the moment you go live: active FPS against the rate you've been holding, average frame render time as a share of the frame budget, and render/output frames skipped. Every row is colored by health and topped with a plain-language verdict (
HEALTHY/STRAINED/DROPPING). Drop counts are measured from the start of the current stream, not OBS's since-launch totals - ποΈ Command palette (
:) β:scene,:profile,:collection,:mute,:vol,:stream,:rec,:scene-profile;Tabcompletes,Ctrl-W/Ctrl-Uedit the line, and results stream in with a typewriter animation - π§ Header β animated gradient identity, daemon/OBS connection chips, active scene/profile, and a render frame indicator
- π Settings view (
F2,Ctrl-T,<Space>ut, or:themes) β btop-style theme picker with live full-UI preview;Enterpersists,Escreverts - π A responsive animated splash (~2s, skippable by any keypress) with a large block logo, slither and liquid-wave loaders, a shimmering
Preparing...braille band, a multi-color progress rail, and staged boot messages
The recording at the top of this section is the whole tour: the splash, the dashboard,
scene switching, the audio matrix, the : command palette, the <Space> which-key menu,
and live theme previews.
It is a GIF here only because GitHub cannot play a terminal recording inline. The source is an asciinema cast, which is the better way to watch it β the text stays sharp at any size and you can pause and copy straight out of it. It plays in the browser on the website, or locally from a clone:
asciinema play docs/demo/obsctl-rs.castDashboard layout, top to bottom: header, status bar, a scenes/audio row (larger, since those lists tend to be longer), a profiles/collections row (smaller), logs, and the command palette. The broadcast status pane takes the top-right 32 columns across both chrome rows; below 76 columns of terminal width it steps aside and the status bar shows inline IDLE/LIVE/REC badges instead. While streaming, the stats panel takes the right-hand 46 columns of the logs strip β on terminals too narrow to fit both, logs keep the full width.
See Themes for the full list of built-in themes and how to define a custom palette.
OBS Studio <ββ obs-websocket 5.x ββ> obsctl server <ββ Unix socket IPC ββ> obsctl tui
<ββ Unix socket IPC ββ> obsctl CLI
Hard rule: only obsctl server connects directly to OBS. The CLI and TUI are thin IPC clients.
Download and install the latest Linux release into ~/.local/bin:
curl --proto '=https' --tlsv1.2 -sSf https://github.com/worxbend/obsctl-rs/releases/latest/download/install.sh | shSet OBSCTL_VERSION to pin a specific release tag, or OBSCTL_INSTALL_DIR to install elsewhere. The script also adds the install directory to PATH in ~/.bashrc and ~/.zshrc if it's missing.
cargo build --release
# Initialize config
./target/release/obsctl init
# Set your OBS WebSocket password
export OBS_WEBSOCKET_PASSWORD='your_password'
# Validate config
./target/release/obsctl validate-config
# Start the daemon
./target/release/obsctl server --headless
# Use CLI commands
./target/release/obsctl server-status
./target/release/obsctl obs-status
./target/release/obsctl dump-config
./target/release/obsctl scene 'Main'
./target/release/obsctl mute 'Mic'
./target/release/obsctl vol 'Mic' 70
# Launch the TUI dashboard
./target/release/obsctl tuiobsctl service install
systemctl --user enable --now obsctl.service
obsctl service statusDefault path: ~/.config/obsctl/config.yml
Override: OBSCTL_CONFIG=/path/to/config.yml or --config PATH
version: 1
server:
socket_path: # optional; defaults to $XDG_RUNTIME_DIR/obsctl/obsctl.sock
allow_remote_shutdown: false
connection:
host: "127.0.0.1"
port: 4455
password_env: "OBS_WEBSOCKET_PASSWORD"
connect_timeout_ms: 3000
request_timeout_ms: 2500
reconnect:
enabled: true
endless: true
initial_delay_ms: 500
max_delay_ms: 10000
multiplier: 1.8
jitter_ms: 250
ui:
refresh_interval_ms: 250
command_palette_prefix: ":" # ":" (vim-style) or "/" β both keys always work
mouse: true # false restores the terminal's own text selection
advanced_ui: true
show_icons: true
theme: "claude" # built-in id, or "custom" β see Themes below
# custom_theme:
# accent: "#D97757"
# locale: "en" # "en" or "uk" β see Localization below
scenes: []
# - name: "Utility BG" # as OBS spells it
# alias: "bg"
# hidden: true # leave it out of every list obsctl shows
scene_profiles: []
# - name: "streaming"
# hidden: ["Utility BG", "Overlay Src"]
# - name: "everything"
# hidden: []
active_scene_profile: # null, or the name of one of the above
audio:
inputs: []
keymap:
quit: ["q", "ctrl+c"]
command_palette: ["/", ":"]
reload_config: ["r"]
dump_config: ["D"]π Security: never set
connection.passwordin plain text. Usepassword_envto point to an environment variable name.
OBS projects often carry scenes that exist only to be nested inside other scenes. They are noise in a scene list, so obsctl can hide them. There are two layers, and the second wins whenever it is switched on.
The baseline is scenes[].hidden. Set it to true on a scene and obsctl leaves that scene out of
the TUI scene list and out of everywhere else obsctl lists scenes. The scene still exists in OBS and can
still be switched to by name; it is only hidden from obsctl's own lists.
A scene profile is a named set of those choices β scene_profiles[] above, each with a name and
a hidden list of scene names spelled as OBS spells them. active_scene_profile names the one in
effect, and matching is case-insensitive. The word "profile" is doing double duty here, so to be
explicit: an obsctl scene profile has nothing to do with an OBS profile (obsctl profile, the
Profiles panel). One is a set of scene-visibility choices that only obsctl knows about; the other is
OBS's own encoder and output configuration.
While a scene profile is active it replaces the per-scene flags rather than adding to them. Every
scene the active profile lists is hidden and every scene it does not list is visible, even one whose
scenes[] entry says hidden: true. That is what lets a profile reveal a scene, and what makes a
profile with an empty hidden list mean "show everything". With no profile active, the scenes[].hidden
flags are the answer and nothing about the default behavior changes.
If active_scene_profile names a profile that is not defined, that is a warning, not an error:
obsctl logs it, falls back to the scenes[].hidden baseline, and carries on. A profile that hides a
scene name absent from a populated scenes: list is also only a warning, and such entries are never
pruned by dump-config β a scene that is temporarily missing must not cost you the profile. An empty
scenes: [], which is what obsctl init writes and what ships above, is not evidence of anything and
produces no such warnings; run dump-config to fill the list in from OBS.
Two caveats about writing this section by hand:
- Any command that writes the config rewrites it from the parsed model. That includes
dump-configand every scene-profile command that changes something βobsctl scene-profile <NAME>,obsctl scene-profile --off,obsctl scene-profile --delete <NAME>, and saving a profile from the TUI. (obsctl scene-profilewith no argument only reports, and writes nothing.) Comments and key ordering in a hand-written config do not survive such a write. A scene-profile command that asks for the state the file is already in β activating the profile that is already active, or--offwhen nothing is active β succeeds without writing anything at all, so it leaves the file, its comments and its modification time exactly as they were. - A config containing
scene_profiles:will not load on an obsctl older than this release. Unknown top-level config keys are rejected outright, so an older binary reports a config error rather than ignoring the key.
ui.advanced_ui defaults to true and enables the animated logo, Unicode borders, icons, segmented meters, braille CPU/memory bars, sparklines, and gradient titles. Set it to false for an ASCII-safe interface: the dashboard, settings, connection view, and splash then use +|- borders, ASCII meters and graphs, ASCII state spinners, plain titles, and ASCII status markers. This switch forces ASCII fallbacks even when show_icons remains enabled.
ui.show_icons: false is a narrower option that hides emoji and decorative symbols while preserving the rest of the advanced interface. For terminals without reliable truecolor support, combine advanced_ui: false with theme: "mono".
Set ui.theme to any of the built-in ids below, or "custom" to use a hand-defined palette. Themes can also be browsed and applied live from the TUI's settings view (F2, Ctrl-T, or /themes in the command palette).
Built-in ids: claude (default), codex, btop, nord, dracula, gruvbox, solarized-dark, monokai, one-dark, tokyo-night, catppuccin-mocha, rose-pine, kanagawa-wave, everforest-dark, ayu-mirage, github-dark, solarized-light, catppuccin-latte, github-light, rose-pine-dawn, night-owl, material-ocean, horizon, iceberg, moonfly, synthwave-84, matrix, zenburn, and mono (TTY-safe, no truecolor). default is accepted as a legacy alias for claude.
Every theme (except mono) paints its own background color over the whole UI rather than showing through the terminal emulator's own background. mono deliberately leaves the background untouched, since it exists for terminals/consoles where truecolor isn't reliable.
Set ui.theme: "custom" and define any subset of colors under ui.custom_theme as "#RRGGBB" hex strings (with or without the #). Any color left unset falls back to the corresponding color from the default (claude) theme, so a minimal override β even just accent β produces a usable theme:
ui:
theme: "custom"
custom_theme:
bg: "#282C34" # terminal background, painted behind the whole UI
accent: "#61AFEF" # app name, focused borders, highlights
accent_alt: "#C678DD" # secondary accent (currently unused by most widgets)
fg: "#ABB2BF" # default body text
muted: "#5C6370" # dimmed/secondary text
border: "#3E4451" # unfocused panel border
border_focus: "#61AFEF" # focused panel border
success: "#98C379" # connected/active/unmuted states
warning: "#E5C07B" # OBS disconnected, shortcuts
danger: "#E06C75" # errors, LIVE/REC badges
info: "#56B6C2" # volume levels, stats readout
highlight_bg: "#61AFEF" # selected list row tint (mixed into bg, see below)
highlight_fg: "#282C34" # selected row text in the settings/which-key popupsThe scenes, audio, profiles, and collections lists don't paint highlight_bg solid.
Terminals have no alpha channel, so the selected row is drawn by mixing highlight_bg
into bg β 32% when the panel has focus, 12% when it doesn't β which leaves the row's
own colors (scene marker, alias, shortcut) showing through instead of flattening them
to highlight_fg. Every theme, built-in or custom, derives its tint from its own
palette. The mono theme is the exception: its bg is Color::Reset so it inherits
your terminal background, and with nothing to blend against it dims the row instead.
obsctl currently ships two locales: English (en) and Ukrainian (uk). English is embedded in the binary and always works; Ukrainian is loaded from a locale file at runtime.
Set the language via ui.locale in config.yml, or override it per-invocation with the OBSCTL_LOCALE env var (takes priority over the config value). An unset or unsupported value falls back to English; obsctl validate-config warns if ui.locale names an unsupported locale.
To enable Ukrainian, copy the bundled translation into your config directory's locales/ subfolder (next to config.yml) and select it:
mkdir -p ~/.config/obsctl/locales
cp contrib/locales/uk.yml ~/.config/obsctl/locales/uk.ymlui:
locale: "uk"If ~/.config/obsctl/locales/uk.yml isn't present, selecting uk silently falls back to the embedded English strings rather than erroring β a missing or broken locale file never blocks the CLI from running.
You can also drop an en.yml into that same locales/ directory to override or add to the embedded English strings (e.g. while working on a translation) without recompiling.
Coverage: the CLI's init/validate-config/service/server output and error-message prefixes, plus the TUI's header and connection screens, are localized. Most other TUI widget text (scenes, audio, logs, settings, command palette) is still English-only; contributions adding more t!() conversions are welcome β see locales/en.yml for the existing key catalog and src/localization.rs for how translations are resolved.
Global options:
--config PATHorOBSCTL_CONFIG=/path/to/config.ymlselects the config file.--jsonmakes proxy command output a stable machine-readable JSON envelope.
| Command | Description |
|---|---|
obsctl init [--force] |
Create default config |
obsctl validate-config |
Validate config file |
obsctl server |
Start daemon in foreground |
obsctl server --headless |
Start daemon for service use |
obsctl tui |
Launch TUI dashboard |
obsctl service install|uninstall|start|stop|restart|status |
Manage systemd user service |
| Command | Description |
|---|---|
obsctl status |
Show combined status |
obsctl server-status |
Show daemon status |
obsctl obs-status |
Show OBS connection status |
obsctl scene <target> |
Change current program scene |
obsctl profile <target> |
Switch OBS profile |
obsctl collection <target> (alias scene-collection) |
Switch OBS scene collection |
obsctl mute <target> |
Mute audio input |
obsctl unmute <target> |
Unmute audio input |
obsctl toggle-mute <target> |
Toggle audio input mute |
obsctl vol <target> <0-100> |
Set input volume by percent |
obsctl volume <target> <0-100> |
Alias for vol |
obsctl scene-profile |
Report which scene profile is active and list the configured ones (same output as scene-profiles) |
obsctl scene-profile <NAME> |
Activate the named scene profile |
obsctl scene-profile --off (alias --clear) |
Stop filtering; each scene's own hidden setting decides again |
obsctl scene-profile --delete <NAME> |
Remove that scene profile from the config; deleting the active one also switches filtering off |
obsctl scene-profiles |
List the configured scene profiles and which one is active |
obsctl dump-config |
Fetch OBS state and merge into config |
obsctl reload-config |
Reload config and rebroadcast state |
obsctl reconnect |
Ask daemon to reconnect to OBS |
obsctl shutdown-server |
Shut down daemon (requires allow_remote_shutdown: true) |
Breaking change in this release.
obsctl scene-profilewith no argument used to clear the active scene profile. It now reports which one is active and writes nothing, because the bare verb is what people type to ask a question and answering it by destroying state is a trap. Scripts that relied on the old meaning must sayobsctl scene-profile --off(or--clear, the same thing). Passing a name still activates that profile, so nothing else about the command changed.
The keymap is vim-flavoured and modelled on AstroNvim: hjkl motions, g-prefixed
jumps, count prefixes, Ctrl-hjkl window moves, and a <Space> leader whose
which-key popup lists what the next keystroke can be.
| Key | Action |
|---|---|
j / k (or β / β) |
Move down / up in the focused panel (the audio matrix rebinds these β see Panes) |
<count>j / <count>k |
Repeat the motion β 12j moves twelve rows |
gg / G (or Home / End) |
Jump to the first / last row, or the leftmost / rightmost audio strip |
Ctrl-D / Ctrl-U (or PgDn / PgUp) |
Move half the focused pane β half its rows, or half its audio strips |
Enter |
Act on the focused row β switch scene/profile/collection, or toggle mute in the audio panel |
| Key | Action |
|---|---|
Ctrl-h/j/k/l or Ctrl-β/β/β/β |
Move focus between panels spatially (2x2 grid) |
Tab / Shift-Tab |
Cycle focus through the panels in reading order |
s / a / p / c |
Focus the scenes / audio / profiles / collections panel |
P |
Switch to the next scene profile β through each profile the config defines, then "no profile" (the scenes[].hidden baseline), then round again |
m |
Mute/unmute the focused audio input |
h / l (or β / β) |
In the audio matrix: move to the previous / next channel strip |
k / j (or β / β) |
In the audio matrix: nudge the focused input's volume by Β±5% β 3k nudges +15% |
| Key | Action |
|---|---|
: |
Open the command palette (/ also works and inserts itself) |
Enter |
Submit |
Tab / Shift-Tab (or β / β, Ctrl-N / Ctrl-P) |
Cycle completions |
Ctrl-W / Ctrl-U |
Delete the last word / clear the line |
Esc, Ctrl-C, or Backspace on an empty line |
Close the palette |
Press <Space> and the which-key popup lists the next key. Groups are marked +.
| Sequence | Action |
|---|---|
<Space>f s/p/c/a/P |
Open the palette pre-filled with scene / profile / collection / toggle-mute / scene-profile |
<Space>P |
Open the scene-profile editor (note the capital: <Space>p is the panel group) |
<Space>N |
Switch to the next scene profile β the same cycle as the bare P |
<Space>p s/a/p/c |
Focus the scenes / audio / profiles / collections panel |
<Space>s s/r |
Toggle streaming / recording |
<Space>c r/d/v |
Reload / dump / validate config |
<Space>o r/s/d/c |
Reconnect to OBS / OBS status / daemon status / reconnect to the daemon |
<Space>u t/i/a |
Theme picker / toggle icons / toggle the advanced UI |
<Space>: |
Open the command palette |
<Space>q |
Quit |
| Key | Action |
|---|---|
F2 or Ctrl-T |
Open/close the settings (theme) view |
Esc |
Cancel a half-typed sequence and snap the log pane back to the live tail |
q |
Quit |
r |
Reload config |
D |
Dump config |
R |
Reconnect this TUI to the daemon |
Ctrl-C |
Quit, or close the palette |
In the settings view: β/β (or j/k, gg/G, and count prefixes) live-preview a
theme across the whole UI, Enter applies and persists it to ui.theme, Esc/F2/q
reverts to the previous theme.
A modal over the dashboard. While it is open it takes every keystroke, so leader sequences and panel motions are inert until it closes. It lists the scene profiles you have, with a "new scene profile" row above them.
The cursor does not start on that "new" row. It starts on the profile that is currently
active, or on the first profile in the config when none is, so that a (activate), d
(delete) and Enter (edit) all act on a real profile the moment the modal opens. The "new"
row is one k away. Only a config with no profiles at all opens on it.
| Stage | Key | Action |
|---|---|---|
| Picker | j/k (or β/β) |
Move between rows |
| Picker | Enter |
Edit the selected profile, or start a new one seeded from what is hidden right now |
| Picker | a |
Activate the selected profile and close |
| Picker | c |
Clear the active profile (back to the scenes[].hidden baseline) and close |
| Picker | d |
Ask whether to delete the selected profile β sends nothing on its own |
| Picker | Esc or q |
Close |
| Delete confirmation | y or Enter |
Delete the profile the footer names |
| Delete confirmation | n, Esc or q |
Keep it |
| Scenes | j/k (or β/β) |
Move between scenes β all of them, hidden ones dimmed |
| Scenes | t |
Hide/reveal the scene under the cursor in this profile, or drop a leftover entry |
| Scenes | n |
Name or rename the profile |
| Scenes | Enter |
Save the profile to the config (asks for a name first if it has none) |
| Scenes | Esc |
Back to the picker, discarding the edit |
| Naming | any printable key, Backspace, Ctrl-W, Ctrl-U |
Edit the name |
| Naming | Enter / Esc |
Accept the name / cancel and restore the previous one |
d asks before it deletes. The daemon removes the profile from the config file and writes no
backup, so there is nothing to undo the delete with, and d sits one key away from a. The
first press replaces the footer with a question naming the profile; while that question is up
every other key in the modal is off, so an a typed at it cannot activate anything, and a
click anywhere answers "no". Only y (or Enter) sends the delete. The CLI's
obsctl scene-profile --delete <NAME> is unchanged and does not ask β it is already an
explicit, typed-out name.
A profile's hidden list is config, and config outlives the scenes it names: rename a scene
in OBS and the profile still lists the old spelling, which now hides nothing. Those leftover
entries are listed after the real scenes, marked as scenes OBS does not have, and t deletes
one instead of toggling it. The picker reads 1 of 2 hidden for such a profile β the first
number is what will actually disappear, the second is what the file says.
None of that judging happens while obsctl has no scene list to judge against β a daemon that
has not finished connecting to OBS. Absence from a list that does not exist is not evidence
of anything, so the editor lists no leftover entries at all in that state and the picker
counts a profile's entries as the file writes them, rather than marking every one of them
stale and inviting you to press t on a profile that is perfectly good.
q is deliberately unbound while toggling scenes, so the muscle-memory quit key cannot
throw away unsaved toggles. Renaming a profile is a single save that moves the existing
entry, so a rename keeps the profile's place in the list and keeps it active if it was
active. A name another profile already uses is refused while you are still typing it,
because saving under it would replace that other profile and there is no backup file to
undo that with. Saving is performed by the daemon, which rewrites the config file β see
Hiding scenes and scene profiles.
You do not have to open the modal to switch profiles: P on the dashboard (or <Space>N)
steps to the next one and, after the last, to no profile at all. While a profile is active
the Scenes panel title names it and says how many scenes it is holding back, so a short
list is never mistaken for a scene list that lost rows β and a profile that hides every
scene leaves that explanation in the empty panel rather than drawing blank.
That badge is the only thing on the dashboard that says a profile is switched on, so it is
drawn whenever one is, even when it is hiding nothing at the moment: a profile whose every
entry names a scene OBS has since renamed reads recording β nothing hidden rather than
disappearing and leaving the dashboard looking exactly as it does with no profile at all.
Say OBS has eight scenes and five of them exist only to be nested inside the other three.
- Start the daemon (
obsctl server, or install the service) and open the TUI withobsctl. The Scenes panel lists all eight. - Press
<Space>thenP. The editor opens. With no profiles defined yet, the cursor is on the "new scene profile" row; pressEnterto start one. - You are now looking at every scene OBS has, each marked
hiddenorvisible. A new profile starts from whatever is hidden right now, so anything already carryinghidden: truein the config is pre-marked. Move withj/kand presston each of the five utility scenes that is not marked yet. Nothing has been written to the config at this point, andEscthrows the whole edit away. - Press
Enter. The editor asks for a name, because the profile does not have one. Typestreamingand pressEnteragain. - The daemon writes the profile into
scene_profiles:in your config file and, because this profile is new, switches it on for you. The status line saysscene profile created and switched on: streaming - hiding 5 scenes, and the editor closes. - Look at the Scenes panel: it now lists three scenes, and its title carries a badge reading
streaming β 5 hidden. That badge is how you tell a working profile from a scene list that is short for some other reason. - Press
P. With one profile defined, that already steps to "no profile": the badge disappears and all eight scenes are listed again. PressPagain to come back tostreaming. Define a second profile andPwalksstreamingβ the second one β no profile β round again.con the picker inside the editor is the other way off.
Editing an existing profile is step 2 with the cursor already on it: Enter loads that
profile's choices, t changes them, Enter saves. Saving an existing profile deliberately
does not switch it on β you may be adjusting one you are not currently using β so if you want
to move to it, press a on it in the picker, or P until you land on it.
Saving the profile that is switched on is the one case where the scene list moves as you
save: the profile you just rewrote is the one deciding what the dashboard shows, so obsctl
re-resolves the list from it immediately. The status line says so rather than claiming
nothing changed β scene profile saved: streaming - it is the active one, so the scene list now hides 5 scenes, against ... it is not the active one, so the scene list is unchanged
for every other save.
The same thing from outside the TUI: obsctl scene-profiles lists what you have,
obsctl scene-profile streaming switches it on, obsctl scene-profile on its own tells you
which is active, obsctl scene-profile --off stops filtering, and
obsctl scene-profile --delete streaming removes it. Every one of those reaches the same
daemon, so a change made in the CLI shows up in a running TUI without a reload.
Mouse reporting is on by default; set ui.mouse: false to turn it off and get the
terminal's own click-to-select and copy-on-drag back (with reporting on, most
terminals fall back to that behavior while Shift is held).
| Input | Action |
|---|---|
| Left-click a row | Focus that panel and select the row; in the audio matrix, click a channel strip |
| Left-click the selected row again | Activate it β switch scene/profile/collection, or toggle mute |
| Wheel over a panel | Move that panel's cursor |
| Wheel over the logs | Scroll the log history; scroll back down (or press Esc) to resume following |
| Left-click the command bar | Open the command palette |
| Wheel over an open palette | Cycle completions |
| Right-click | Cancel β closes the palette, the which-key popup, or the settings view |
| Click on the "daemon unavailable" screen | Retry the connection |
| Click a theme in the settings view | Preview it; click it again to apply |
| Click or scroll a row in the scene-profile editor | Move its cursor (the editor is modal, so nothing reaches the dashboard behind it) |
| Click the selected profile row again | Activate that scene profile β the same as pressing a on it |
| Click while the editor is asking about a delete | Answer "no" β the mouse cannot confirm a delete, only y can |
Type : to open the palette, then use any of:
:help
:themes
:scene <target>
:set-scene <target>
:profile <target>
:set-profile <target>
:collection <target>
:scene-collection <target>
:mute <target>
:unmute <target>
:toggle-mute <target>
:vol <target> <0-100>
:scene-profile <name>
:set-scene-profile <name>
:scene-profile-off
:scene-profile-clear
:scene-profile-delete <name>
:delete-scene-profile <name>
:status
:server-status
:obs-status
:validate-config
:reconnect
:dump-config
:reload-config
:quit
/ is accepted as a prefix everywhere : is, so /scene Main and :scene Main are
the same command. ui.command_palette_prefix picks which one <leader> mappings and
mouse clicks insert.
Target names resolve in this order:
- Exact shortcut
- Exact alias
- Exact OBS name
- Case-insensitive alias
- Case-insensitive OBS name
Ambiguous matches fail without sending any OBS request.
| Code | Meaning |
|---|---|
| 0 | Success |
| 1 | Generic failure |
| 2 | Config error |
| 3 | Server/connection/auth error |
| 4 | OBS request error |
| 5 | Command parse error |
| 6 | IPC error |
There are two intentional exit-code mappings:
- Local process failures use the local error classification. These are failures before a daemon IPC response exists, such as
init,validate-config,server, service management, startup, config loading, or socket connection setup. - Proxy commands that receive a daemon response use the public IPC error-code table below. That table is the stable daemon-reachable contract for CLI and TUI clients.
These mappings are separate because the same underlying condition can have different process context. For example, a local startup/authentication failure exits as a server/connection failure, while a reachable daemon that cannot use OBS reports OBS_UNAVAILABLE to proxy clients.
obsctl is daemon-first in normal use. Proxy commands connect to the local Unix socket, send one IPC command to the already-running daemon, wait for the correlated response, print the result, and exit. They do not connect directly to OBS and they do not auto-start the daemon.
Proxy commands include status, server-status, obs-status, scene, profile, mute, unmute, toggle-mute, vol, volume, scene-profile, scene-profiles, dump-config, reload-config, reconnect, and shutdown-server.
Without --json, command output is concise and human-readable. Successful command results are printed to stdout. Diagnostics and errors are printed to stderr.
With --json, stdout is the machine-readable contract and stderr is not used for human diagnostics. All proxy command outcomes use the same envelope with stable ok, result, error, and exit_code fields:
{
"ok": true,
"result": {
"message": "scene set: Main"
},
"error": null,
"exit_code": 0
}On failure, ok is false, result is null, and error contains a public IPC error code plus a secret-safe message. The exit_code field is the same code returned by the process:
{
"ok": false,
"result": null,
"error": {
"code": "OBS_UNAVAILABLE",
"message": "OBS is unavailable"
},
"exit_code": 4
}If a future or third-party daemon returns an unknown error.code, the CLI preserves that string in the JSON envelope and exits 1.
Config errors returned by the daemon keep the same envelope and exit with code 2:
{
"ok": false,
"result": null,
"error": {
"code": "CONFIG_INVALID",
"message": "config invalid: invalid field"
},
"exit_code": 2
}If no IPC response exists because the daemon is not reachable, --json still prints only the failure envelope to stdout:
{
"ok": false,
"result": null,
"error": {
"code": "SERVER_UNAVAILABLE",
"message": "obsctl server is not running.\nStart it with:\n obsctl server --headless\nOr install the service:\n obsctl service install\n systemctl --user enable --now obsctl.service"
},
"exit_code": 3
}In non-JSON mode, the same local server-unavailable case prints a human diagnostic to stderr:
server unavailable at <socket-path>: obsctl server is not running.
Start it with:
obsctl server --headless
Or install the service:
obsctl service install
systemctl --user enable --now obsctl.service
IPC error responses use this shape:
{
"id": "req-000001",
"type": "response",
"ok": false,
"error": {
"code": "REQUEST_TIMEOUT",
"message": "request timed out"
}
}Public error codes are stable and map to CLI exit codes as follows:
| Code | Meaning | CLI exit |
|---|---|---|
CONFIG_INVALID |
Config file is missing, invalid, or failed validation | 2 |
SERVER_UNAVAILABLE |
Local daemon/socket connection failed before a valid command response | 3 |
OBS_UNAVAILABLE |
Daemon is reachable, but OBS is not currently connected or usable | 4 |
REQUEST_TIMEOUT |
An OBS request exceeded connection.request_timeout_ms |
4 |
OBS_REQUEST_FAILED |
OBS returned a request failure or the daemon could not process the OBS response | 4 |
SCENE_NOT_FOUND |
Scene target could not be resolved | 4 |
AUDIO_INPUT_NOT_FOUND |
Audio input target could not be resolved | 4 |
PROFILE_NOT_FOUND |
Profile target is not one OBS knows | 4 |
SCENE_COLLECTION_NOT_FOUND |
Scene collection target is not one OBS knows | 4 |
ALIAS_AMBIGUOUS |
Target matched more than one configured alias/name | 1 |
COMMAND_PARSE_ERROR |
Command name or arguments are invalid | 5 |
IPC_TIMEOUT |
The daemon accepted the connection but did not answer within 30s | 6 |
IPC_PROTOCOL_ERROR |
IPC frame or response shape is invalid for the protocol | 6 |
SHUTDOWN_DISABLED |
Remote daemon shutdown is disabled in config | 1 |
SERVER_ERROR |
Generic daemon-side failure | 1 |
IPC_TIMEOUT and REQUEST_TIMEOUT are also distinct, and the difference is which hop stalled. REQUEST_TIMEOUT is reported by the daemon when OBS did not answer it. IPC_TIMEOUT is produced by the client itself when the daemon accepted the connection and then did not answer at all β nothing arrives over IPC to report, so the client gives up after 30 seconds and exits 6 rather than blocking forever. The budget is deliberately far above anything the daemon does on purpose (dump-config, the slowest command, makes two OBS round trips plus a config write), because it exists to bound a wedged daemon, not to police a slow one.
REQUEST_TIMEOUT and OBS_UNAVAILABLE are intentionally distinct. OBS_UNAVAILABLE means the daemon cannot currently make OBS requests because OBS is disconnected, authentication failed, or the OBS connection is otherwise unavailable. REQUEST_TIMEOUT means a request was attempted against OBS, but no matching response arrived before connection.request_timeout_ms.
Bad subscription topics are protocol failures. A subscribe request containing any topic outside state, events, or logs returns IPC_PROTOCOL_ERROR with a message naming the unknown topic; INVALID_TOPIC is not emitted by the frozen wire contract.
Error messages are intended to be actionable and secret-safe. They must not include OBS passwords, authentication strings, bearer tokens, or resolved secret environment-variable values.
Transport is newline-delimited JSON over a Unix domain socket.
Command request:
{"id":"req-000001","type":"command","command":{"name":"set_scene","target":"Main"}}Subscribe request:
{"id":"req-000002","type":"subscribe","topics":["state","events","logs"]}Success response:
{"id":"req-000001","type":"response","ok":true,"result":{"message":"scene set: Main"}}Error response:
{"id":"req-000001","type":"response","ok":false,"error":{"code":"OBS_UNAVAILABLE","message":"OBS is unavailable"}}Scene-profile commands:
{"id":"req-000003","type":"command","command":{"name":"set_scene_profile","target":"streaming"}}
{"id":"req-000004","type":"command","command":{"name":"clear_scene_profile"}}
{"id":"req-000005","type":"command","command":{"name":"delete_scene_profile","target":"streaming"}}
{"id":"req-000006","type":"command","command":{"name":"list_scene_profiles"}}
{"id":"req-000007","type":"command","command":{"name":"save_scene_profile","target":"streaming","hidden":["Utility BG","Overlay Src"]}}
{"id":"req-000008","type":"command","command":{"name":"save_scene_profile","target":"night","hidden":["Utility BG"],"rename_from":"streaming"}}save_scene_profile is the only command carrying a list, and the only one with an optional argument.
target and hidden are mandatory: hidden must be present and must be an array, and an empty array
means "this profile hides nothing". A profile may name at most 128 scenes, and each name is subject
to the same 256-character, no-control-character limit as any other target; a payload that breaks either
rule is rejected whole with COMMAND_PARSE_ERROR, as is one carrying any key other than these three.
rename_from names the profile the save is replacing, and is what makes a rename one command instead
of a save followed by a delete. Given it, the daemon replaces that entry where it stands β keeping its
position in the list, and moving active_scene_profile onto the new name if that profile was the
active one. Renaming onto a name a different profile already answers to is CONFIG_INVALID rather
than a silent overwrite. Left out (the request above it), the save is a plain upsert: it creates the
profile, or replaces one of the same name whole. The reply reports created and renamed so a client
can tell which of the three happened, and active, which is true when the profile just written is
the one in effect β the daemon re-resolves the scene list as it writes, so that is the case where the
save moved the caller's scene list even though a save never switches a profile on.
set_scene_profile and save_scene_profile both report two counts. hidden is how many scenes OBS
actually has that the profile takes off the list β what the user will watch disappear β and listed
is how many names the profile holds in the config file. They differ whenever the profile still names a
scene OBS has renamed or deleted. With no scene list known yet (a daemon that has not finished talking
to OBS), hidden falls back to listed, because nothing better can be said.
Naming a scene profile that does not exist in set_scene_profile or delete_scene_profile is
CONFIG_INVALID, as is any of the four mutating commands on a daemon started without a config file
path, or on one whose config file is present but cannot be read β that file is left untouched rather
than replaced from the daemon's in-memory copy, because unlike dump-config these commands write no
backup. list_scene_profiles needs none of that: it answers from memory and never touches the file.
All four mutating commands (set, clear, save, delete) write the config file and then publish a
fresh state snapshot, so a client does not need to read anything back after one.
State event:
{"type":"event","topic":"state","data":{"connected":true}}The state payload is the full snapshot; the sample above is abbreviated. Its scene-visibility
fields are scenes[].hidden β already resolved by the daemon, so a client renders it directly rather
than re-deriving it β plus the scene profiles themselves and the active one:
{"type":"event","topic":"state","data":{
"scenes":[{"name":"Main","alias":"main","shortcut":"1","group":"live","active":true,"hidden":false},
{"name":"Utility BG","alias":null,"shortcut":null,"group":null,"active":false,"hidden":true}],
"scene_profiles":[{"name":"streaming","hidden":["Utility BG"]}],
"active_scene_profile":"streaming"}}scene_profiles and active_scene_profile were added in this release. Both are optional on the
wire: a client written against an older snapshot still parses a newer one, and active_scene_profile
is null when no profile is in effect. It is only ever a name the daemon can actually resolve β a
config pointing at a profile that does not exist publishes null, not the dangling name.
OBS event:
{"type":"event","topic":"events","data":{"type":"CurrentProgramSceneChanged","scene_name":"BRB"}}Typed log event:
{"type":"event","topic":"logs","data":{"level":"info","message":"daemon listening","target":"obsctl_rs::server","timestamp":"1970-01-01T00:00:00Z"}}The events topic publishes only known normalized scene and audio OBS events. Unknown, vendor-specific, or newly added OBS events are intentionally dropped instead of being forwarded as raw obs-websocket envelopes.
Current public OBS event payloads are:
{"type":"event","topic":"events","data":{"type":"CurrentProgramSceneChanged","scene_name":"BRB"}}
{"type":"event","topic":"events","data":{"type":"SceneListChanged"}}
{"type":"event","topic":"events","data":{"type":"InputCreated","input_name":"Mic"}}
{"type":"event","topic":"events","data":{"type":"InputRemoved","input_name":"Mic"}}
{"type":"event","topic":"events","data":{"type":"InputMuteStateChanged","input_name":"Mic","muted":true}}
{"type":"event","topic":"events","data":{"type":"InputVolumeChanged","input_name":"Desktop Audio","volume_mul":0.75,"volume_db":-2.5}}
{"type":"event","topic":"events","data":{"type":"CurrentProfileChanged","profile_name":"Streaming"}}
{"type":"event","topic":"events","data":{"type":"ProfileListChanged"}}Scene-list mutation events from OBS, including scene created, removed, renamed, and reindexed notifications, are exposed publicly as SceneListChanged. The public payload does not preserve a reason field for those mutations.
OBS event payloads currently do not include timestamps, raw OBS event names, or stable event IDs. Those fields should be treated as absent from the public wire contract unless added in a future compatibility update.
For log events, level is one of trace, debug, info, warn, or error; target may be omitted; and timestamp is RFC3339 UTC. Supported event topics are state, events, and logs.
Error and log messages are redacted by a best-effort boundary sanitizer. Prefer structured non-secret fields over formatted messages that include secret-bearing values.
Compatibility note: INVALID_TOPIC is not a public wire code in this release. Clients that previously treated invalid subscription topics specially should handle IPC_PROTOCOL_ERROR for that case. No other public wire code is intentionally renamed or removed.
Default log path: ~/.local/state/obsctl/obsctl.log
Control level with --log-level debug|info|warn|error in server mode.
Passwords and authentication strings are never logged.
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-targets --all-features
cargo build --releaseTests use a fake OBS WebSocket server and fake IPC servers β no real OBS required.
Released under the MIT License.
only obsctl server talks to OBS
