Skip to main content

OBS Integration

Overview

Lumio integrates with OBS Studio for remote control (scenes, stream/recording start-stop, status monitoring). Three distinct paths exist, and they do not share a transport:

PathWho connects to obs-websocketState
/dashboard/obs and /popout/obsnobody yet — both pages call the REST routes under /v1/obs-remote/*, whose server-side transport is planned but not yet wiredStatus honestly reports available: false (remote control not yet live); the scene/stream/recording controls return 409 Conflict "OBS remote control is not yet available" instead of pretending to succeed
Overlay pages (/overlay/[key])the browser, from inside OBS, using ObsWebSocketClient from @lumio/obsWorking. Credentials arrive in the WebSocket bootstrap payload; obs:action events pushed by automations are executed locally
testObsConnection / POST /v1/integrations/obs/testthe API server, using lo_obs_remote::ObsRemoteClientWorking, but only a connect-and-disconnect probe with a 10-second timeout
PieceFlagWhat it is
OBS Remote feature umbrellafeature:obs_remoteGates every OBS query, mutation and REST route, plus the pages /dashboard/obs and /popout/obs. This is the master switch for the whole feature.
WebSocket transportintegration:obs_websocketThe stored obs-websocket connection details (port + password + optional remote host). Configured by the streamer in the /dashboard/connections Integrations modal. Also gates whether overlay bootstrap payloads carry OBS credentials.
Browser Source overlay capabilitywidget:obs_browser_sourceReserved flag for overlay widgets that would consume the window.obsstudio JS API through useObsBrowserSource(). Seeded and toggleable, but no code path reads it today. Classified under widget:* because it is an overlay-side capability.

Gating semantics per flag:

  • feature:obs_remote off/dashboard/obs (via FeatureRouteGate on its layout.tsx) and /popout/obs (via an SSR feature check) render <FeatureDisabledPage>, and every OBS query, mutation and REST route rejects the request.
  • integration:obs_websocket off → write endpoints for the WebSocket integration (PUT /v1/integrations/obs, DELETE /v1/integrations/obs, GraphQL saveObsConfig, deleteObsConfig) reject the request, and overlay bootstrap payloads omit the obs credentials block. Read endpoints still return existing config for visibility. The user can't add/save/delete the WebSocket connection in the Integrations modal.
  • widget:obs_browser_source off → no observable effect. No widget renderer, settings panel or editor-picker entry consumes this flag, and ObsBrowserSourceProvider mounts unconditionally at the overlay root (it gates internally on window.obsstudio availability). A future overlay widget that reacts to OBS state should check useFeature("widget:obs_browser_source") itself.

Admins toggle all three flags on /admin/feature-flags. Per-plan or per-account overrides work the same as any other feature flag — see the admin feature-flags doc.

The stored obs-websocket connection

integration:obs_websocket covers the connection details Lumio stores for an account. Lumio does not hold a long-lived socket to OBS on the server; it stores the credentials and hands them to whichever surface needs them.

  • Requires: the user installs OBS, enables the built-in obs-websocket server (Tools → obs-websocket Settings), notes the port and optional password.
  • User configuration: /dashboard/connections → Integrations section → Add integration → OBS WebSocket. Fields: port (default 4455), password (optional), remote_enabled toggle, remote_host (only when remote_enabled is true).
  • Password encryption: AES-256-GCM via crypto::encrypt() with a key derived from auth.token_encryption_key. Stored in the JSONB config column of integration_configs.
  • Save semantics: PUT /v1/integrations/obs / saveObsConfig writes the whole config object as an upsert. Submitting an empty or omitted password clears the stored password rather than leaving the previous one in place.
  • Remote-host validation: when remote_host is set it is resolved and rejected if it points at a loopback, private, link-local, unspecified or broadcast address (IPv4 and IPv6, including ULA fc00::/7 and fe80::/10) — an SSRF guard, so remote_host must be a public address.
  • Transport: TCP WebSocket to ws://{host}:{port}. Host defaults to localhost; remote mode requires a publicly reachable host (and port-forwarding in the user's router).

Browser Source overlay capability

widget:obs_browser_source is the reserved flag for overlay widgets that integrate with the window.obsstudio JS API. OBS injects this API into any page loaded as a Browser Source inside OBS. The provider and hook described below exist and are mounted; no widget consumes them yet, and nothing reads the flag.

ObsBrowserSourceProvider

The ObsBrowserSourceProvider is mounted unconditionally at the overlay root (OverlayClient for the live overlay, OverlayPreview for editor preview). It:

  1. Detects window.obsstudio presence at mount time (SSR-safe: no-op on the server).
  2. Fetches initial state in parallel: plugin version, control level, stream/recording/replay-buffer/virtualcam status, current scene, scene list, transition list, current transition.
  3. Subscribes to every event in the OBSEventType union except obsReplaybufferSaved (which needs no state change) — scene and scene-list changes, transition and transition-list changes, the four streaming and six recording transitions, the four replay-buffer transitions, virtualcam start/stop, source visibility/active changes, and obsExit (which resets the provider to a disconnected state).
  4. Runs status polling every 2 000 ms as a drift-correction backup.
  5. Cleans up all listeners and polling on unmount.

When OBS is not detected, the Provider supplies no-op defaults — all methods are safe to call unconditionally.

useObsBrowserSource()

Consumer widgets import useObsBrowserSource from @/contexts/obs-browser-source-context to read live OBS state:

import { useObsBrowserSource } from "@/contexts/obs-browser-source-context";

function MyWidget() {
const { available, currentScene, status, setScene } = useObsBrowserSource();
// ...
}

Available fields:

FieldTypeDescription
availablebooleanTrue when window.obsstudio was detected
pluginVersionstring | nullobs-browser plugin version
controlLevelOBSControlLevelCurrent control permissions (NONE/READ_OBS/READ_USER/BASIC/ADVANCED/ALL)
statusOBSStatus | nullstreaming / recording / recordingPaused / replayBuffer / virtualcam booleans
currentScenestring | nullActive scene name
currentSceneInfoOBSScene | nullActive scene object including canvas width and height (useful when canvas dimensions are needed)
scenesstring[]All scene names
transitionsstring[]All available transition names
currentTransitionstring | nullActive transition name
canRead()() => booleancontrolLevel >= READ_OBS (1)
canReadUser()() => booleancontrolLevel >= READ_USER (2)
canControl()() => booleancontrolLevel >= BASIC (3)
canModify()() => booleancontrolLevel >= ADVANCED (4)
canFullControl()() => booleancontrolLevel == ALL (5)

Control methods (setScene, setTransition, startStreaming, stopStreaming, startRecording, stopRecording, pauseRecording, unpauseRecording, startReplayBuffer, stopReplayBuffer, saveReplayBuffer, startVirtualcam, stopVirtualcam) are no-ops when OBS is not available. Capability helpers canReadUser() and canControl() are also available (see table above).

Outbound event relay (not implemented)

Forwarding OBS Browser Source events from the overlay to the Lumio backend is not implemented. The call site is marked with a TODO in apps/web/src/contexts/obs-browser-source-context.tsx, and a matching TODO block in apps/web/src/hooks/use-obs-websocket.ts sketches the intended shape. The reverse direction — backend → overlay → OBS — does work; see below.

Overlay control relay

This is the OBS control path that actually reaches OBS today, and it runs entirely through the overlay:

  1. The account has an OBS integration config and at least one overlay layer of type obs_browser_source (or obs).
  2. When that overlay's WebSocket session starts, OverlayBootstrapProvider (apps/api/src/services/bootstrap_provider.rs) decrypts the stored password and adds an obs block (port, password, remote_enabled, remote_host) to the bootstrap payload — but only when integration:obs_websocket is enabled for the account and the overlay is authenticated with a permanent token. Shared overlay tokens never receive credentials; a shared link may instead carry them explicitly as URL params.
  3. useObsLocalConnect (apps/web/src/hooks/use-obs-local-connect.ts) opens an obs-websocket v5 connection from the browser to the local OBS instance using those credentials, and reconnects automatically if OBS restarts.
  4. An automation's OBS-control action publishes to the Redis channel lumio:obs:action:{account_id} (apps/api/src/dispatch.rs), which reaches the overlay as an obs:action WebSocket event.
  5. handleObsRelayAction (apps/web/src/hooks/use-obs-websocket.ts) dispatches it to the local client. Supported actions: set_current_scene, set_current_transition, set_muted, set_volume, set_filter_enabled, start_stream, stop_stream, start_recording, stop_recording. An unknown action is logged and ignored.

Key files

PathDescription
apps/web/src/contexts/obs-browser-source-context.tsxProvider + context + useObsBrowserSource hook
apps/web/src/app/(overlay)/overlay/[key]/overlay-client.tsxMounts ObsBrowserSourceProvider, calls useObsLocalConnect, routes obs:action events
apps/web/src/components/overlay/overlay-preview.tsxMounts ObsBrowserSourceProvider for the editor preview
apps/web/src/hooks/use-obs-local-connect.tsBrowser-side obs-websocket connection from bootstrap credentials
shared/obs/src/client.tsObsBrowserSource class (typed window.obsstudio wrapper)
shared/obs/src/types.tsOBSStatus, OBSScene, OBSTransition, OBSEventType, OBSControlLevel

There is no ObsBrowserSourceWidget renderer and no ObsBrowserSourceSettings panel — the overlay editor has no picker entry for this capability, so an obs_browser_source layer cannot currently be created from the editor UI even though the bootstrap provider recognises the layer type.

Admin configuration

Toggle transports globally

/admin/feature-flags — flip any of:

  • feature:obs_remote — kills the entire OBS remote feature for all accounts
  • integration:obs_websocket — kills the stored WebSocket connection globally (users can't add or edit the OBS integration, and overlays stop receiving credentials)
  • widget:obs_browser_source — reserved; toggling it has no runtime effect today

Per-plan / per-account overrides

  • Plan overrides: use the plan's feature-settings in /admin/plans/{slug} to disable any of the three flags for specific tiers.
  • Account overrides: use /admin/accounts/{id}/features to set a per-account override.

See the admin feature-flags doc for the resolution chain.

User configuration

Dashboard UI

  • /dashboard/obs — the OBS Remote page. Gated on feature:obs_remote via FeatureRouteGate on its layout.tsx. Renders a connection badge, a scene grid, and stream/recording toggle buttons, all driven by GET /api/obs-remote/status and POST /api/obs-remote/{scene,stream,recording}. It contains no port/password form — connection details are edited on /dashboard/connections.
  • /popout/obs — compact version of the same page, designed to be opened as a docked browser window next to OBS (e.g. via OBS's "Custom Browser Docks" or a standalone browser window). Same feature:obs_remote gate, same REST routes, polling status every 5 s. NOT designed to be loaded as a Browser Source inside OBS.
  • /dashboard/connections — the Integrations modal's "OBS WebSocket" entry, shown only when useFeatureStatus("integration:obs_websocket") reports enabled. Where the user configures port / password / remote toggle / remote host.

Because the /v1/obs-remote/* server-side transport is not yet wired, GET /v1/obs-remote/status returns available: false and both pages render an honest "remote control coming soon" state; a control click returns 409 Conflict ("OBS remote control is not yet available") rather than a fake success.

When /popout/obs is opened with a popout token (?token=lm_pop_…), the token is exchanged once for a short-lived popout session cookie (see Tokens). The page then gates on accountFeatures instead of me, and the popout-aware fetch tags requests so the proxy forwards the session cookie and self-heals a lapsed session. The session resolves to the same popout-token authorization context, so retrieving the decrypted OBS WebSocket password (GET /v1/integrations/obs/credentials, popout-token-only) keeps working — a dashboard login still cannot read it. Saved OBS-dock URLs carrying the old ?token= continue to work unchanged.

Both pages fail open on an unreachable API during the SSR feature check: when the feature query throws, the page renders rather than showing the disabled screen.

What the user sees when a flag is off

  • feature:obs_remote off → both /dashboard/obs and /popout/obs show the generic FeatureDisabledPage with reason (global_off / plan_locked / account_override).
  • integration:obs_websocket off → "OBS WebSocket" entry disappears from the Integrations Add modal; attempts to save/delete config via direct API call return Feature 'integration:obs_websocket' is not available. Existing saved config is still readable. Overlay bootstrap payloads stop carrying OBS credentials.
  • widget:obs_browser_source off → no user-facing impact today (no overlay widget currently uses the window.obsstudio API). Reserved for future overlay-widget features that integrate with the Browser Source JS API.

Architecture

Backend

  • GraphQL (apps/api/src/graphql/obs.rs) — queries for config, status, remote connection status; mutations for save/delete config, test connection, and stream/recording/scene control. Write-mutations are gated by three guards chained via FeatureGuard::new("feature:obs_remote").and(FeatureGuard::new("integration:obs_websocket")).and(PermissionGuard::new("obs:edit")) so BOTH feature flags AND the permission must be satisfied.
  • REST (apps/api/src/routes/obs_integration.rs) — mirrors the GraphQL mutations with identical guard order: require_permission("obs:edit")require_feature("feature:obs_remote")require_feature("integration:obs_websocket"). Same error messages and codes as the GraphQL side per the parity rule.

Note: the server-side remote-control transport is not yet wired, so the surface reports its state honestly rather than fabricating one (ZAF-1090). The obsRemoteStatus query and GET /v1/obs-remote/status return available: false; the control mutations (obsControlStream, obsControlRecording, obsSwitchScene) and their /v1/obs-remote/* REST twins return a 409 Conflict "OBS remote control is not yet available" (CONFLICT) on both protocols instead of a fake success. The WorkerManager integration is a planned follow-up; until it ships, no caller receives a success from these control endpoints.

  • OBS Remote Client (crates/lo-obs-remote/) — ObsRemoteClient handles WebSocket connections to OBS Studio, including authentication with the encrypted password. Today its only live caller is the connection test.
  • OBS Remote worker (apps/api/src/workers/obs_remote.rs) — a per-account worker that would hold the OBS connection, poll ingest stats via IngestMonitor and emit events on threshold violations (low bitrate, high dropped frames, connection lost, recovery) with exponential-backoff reconnect. The code is complete but nothing spawns it, which is why remote control is not yet available and the control endpoints return 409 Conflict.
  • Configuration storage — OBS config is stored as JSONB in the integration_configs table with platform = "obs", label = "OBS WebSocket", unique on (account_id, platform). The password field is encrypted using AES-256-GCM.

Frontend

  • /dashboard/obs — Scene grid plus stream/recording toggles and a connection badge, driven by the /api/obs-remote/* proxy routes. No port/password form.
  • /popout/obs — Compact dock version of /dashboard/obs using the same /api/obs-remote/* routes through a popout-aware fetch, polling every 5 s. Designed to be opened as a docked browser window alongside OBS. NOT loaded as a Browser Source and does NOT use window.obsstudio.
  • /dashboard/connections — Integrations modal with "OBS WebSocket" entry gated by useFeatureStatus("integration:obs_websocket"); the ObsConfigCard holds the port / password / remote-toggle / remote-host form.
  • /overlay/[key] — the only surface that actually drives OBS, via a browser-side obs-websocket connection (see Overlay control relay).

apps/web/src/hooks/use-obs-websocket.ts exports a useObsWebSocket hook that fetches credentials via the popout-token endpoints and manages a client lifecycle. No component calls it — only its handleObsRelayAction export is in use.

API

GraphQL Queries

QueryPermissionDescription
obsConfigobs:readFull OBS config: port, remote_enabled, remote_host, has_password flag, timestamps. Never exposes the actual password.
obsStatusobs:readLightweight status: configured flag, port, remote settings.
obsRemoteStatus (STUB)obs:readRemote connection status from OBS worker: connected flag, stream/recording status, scene list, current scene. Currently always returns disconnected/empty — WorkerManager integration pending.

GraphQL Mutations

MutationGuardsDescription
saveObsConfig(port?, password?, remoteEnabled?, remoteHost?)feature:obs_remote + integration:obs_websocket + obs:editSave (upsert) OBS config. Port defaults to 4455. Password is encrypted before storage.
deleteObsConfigfeature:obs_remote + integration:obs_websocket + obs:deleteDelete OBS integration config.
testObsConnectionfeature:obs_remote + obs:editTest the obs-websocket connection from the API server. Requires remote_enabled — otherwise it errors with Connection test is only available when remote mode is enabled. 10-second timeout; connection failures come back as { success: false, error } rather than a GraphQL error.
obsControlStream(action) (STUB)feature:obs_remote + obs:editControl streaming: "start" or "stop". Currently always returns success — WorkerManager integration pending.
obsControlRecording(action) (STUB)feature:obs_remote + obs:editControl recording: "start" or "stop". Currently always returns success — WorkerManager integration pending.
obsSwitchScene(sceneName) (STUB)feature:obs_remote + obs:editSwitch the active OBS scene. Currently always returns success — WorkerManager integration pending.

REST Endpoints

MethodPathGuardsDescription
GET/v1/integrations/obsobs:read + feature:obs_remoteGet OBS config (has_password flag only, never the password).
PUT/v1/integrations/obsobs:edit + feature:obs_remote + integration:obs_websocketSave OBS config (upsert).
DELETE/v1/integrations/obsobs:delete + feature:obs_remote + integration:obs_websocketDelete OBS config. Returns 204.
GET/v1/integrations/obs/credentialspopout-token auth only + feature:obs_remoteGet decrypted OBS credentials. Carries no permission check — instead it rejects any auth context that is not a Popout Token with 403 This endpoint requires popout token authentication, so a normal dashboard login can never read the password.
GET/v1/integrations/obs/statusobs:read + feature:obs_remoteGet OBS configuration status (configured, port, remote settings).
POST/v1/integrations/obs/testobs:edit + feature:obs_remoteTest the obs-websocket connection from the API server.
GET/v1/obs-remote/status (not yet wired)obs:read + feature:obs_remoteRemote connection status. Returns available: false with connected: false and empty scenes while the transport is not yet live.
POST/v1/obs-remote/scene (not yet wired)obs:edit + feature:obs_remoteSwitch scene. Body { "scene_name": "..." }. Returns 409 Conflict "OBS remote control is not yet available".
POST/v1/obs-remote/stream (not yet wired)obs:edit + feature:obs_remoteStart/stop streaming. Body { "action": "start" | "stop" }. Returns 409 Conflict "OBS remote control is not yet available".
POST/v1/obs-remote/recording (not yet wired)obs:edit + feature:obs_remoteStart/stop recording. Body { "action": "start" | "stop" }. Returns 409 Conflict "OBS remote control is not yet available".

REST payloads are snake_case (remote_enabled, remote_host, has_password, scene_name, current_scene, created_at).

Read endpoints are intentionally NOT gated on integration:obs_websocket — existing configurations remain visible in the UI even when the transport flag is switched off for an account. Only WRITE paths (create/update/delete) are blocked so admins can disable new adoption without breaking existing users' views.

All four /v1/obs-remote/* handlers enforce the plan feature feature:obs_remote (require_feature, fail-closed) — the same guard the GraphQL surface applies via FeatureGuard — before doing anything else. There is no separate require_pro helper (a former no-op placeholder was removed); the feature flag is the plan gate. See Plan gating below.

Permissions

PermissionDescription
obs:readRead OBS configuration and status
obs:editEdit OBS configuration, test connection, control stream/recording/scenes
obs:deleteDelete OBS configuration

Database

TableDatabaseDescription
integration_configsPostgreSQLid, account_id, platform ("obs"), label ("OBS WebSocket"), enabled, config (JSONB), created_at, updated_at. Unique on (account_id, platform).

Config JSONB Structure

{
"port": 4455,
"password": "encrypted_string_or_null",
"remote_enabled": false,
"remote_host": "192.168.1.100"
}
  • port — OBS WebSocket port (default 4455)
  • password — Encrypted OBS WebSocket password (null if not set)
  • remote_enabled — Whether remote (non-localhost) connections are enabled
  • remote_host — Remote host address (only used when remote_enabled is true)

Data Flow

  1. User configures OBS settings (port, optional password, optional remote host) in /dashboard/connections → Integrations → OBS WebSocket.
  2. remote_host is validated against the private/loopback/link-local block-list, the password is encrypted via crypto::encrypt(), and the whole object is upserted into the integration_configs JSONB config.
  3. User can test the connection: the API server decrypts the password and attempts an obs-websocket handshake to ws://{remote_host}:{port} with a 10-second timeout, then disconnects.
  4. Stream/recording/scene commands from /dashboard/obs or /popout/obs reach /v1/obs-remote/*, which — after the obs:edit + feature:obs_remote gates — returns 409 Conflict "OBS remote control is not yet available" because the command channel to the OBS worker is not yet wired. Nothing is forwarded to OBS.
  5. For overlays, the credentials travel in the WebSocket bootstrap payload and the browser connects to OBS directly; automation-issued obs:action events are executed there. See Overlay control relay.

Overlay-widget Browser Source integration

Overlay widgets under /overlay/[key] can consume useObsBrowserSource() to react to OBS state (e.g. auto-hide when a specific scene is active). The ObsBrowserSourceProvider is already mounted at the overlay root — no additional setup is needed. To gate a widget's OBS-reactive behaviour on the feature flag, check useFeature("widget:obs_browser_source") inside the widget component.

Plan gating

OBS is a paid-tier feature, and the gating lives entirely in the plan_features matrix: feature:obs_remote, integration:obs_websocket and widget:obs_browser_source are all seeded disabled for the Free plan and enabled for Pro. Free and Pro are the only plans. Because the flags gate every OBS query, mutation and route, a Free account cannot use OBS at all — the restriction is not limited to remote_enabled: true.

There is no separate plan-limit column. The historical account_limits.obs_remote_allowed boolean was migrated into the feature:obs_remote account override and dropped. Enforcement is the feature:obs_remote guard itself — require_feature on every /v1/obs-remote/* route and FeatureGuard on every GraphQL field, both fail-closed. (A former no-op require_pro placeholder in apps/api/src/routes/obs_remote.rs was removed in ZAF-1090; it protected nothing.)

Key Files

PathDescription
apps/api/src/graphql/obs.rsGraphQL queries and mutations with three-guard chain
apps/api/src/routes/obs_integration.rsREST endpoints with identical guard chain
apps/api/src/routes/obs_remote.rsRemote control routes. Not yet wired to WorkerManager/Redis, so they report honestly: status returns available: false; scene/stream/recording controls return 409 Conflict. Each still enforces feature:obs_remote.
apps/api/src/workers/obs_remote.rsPer-account OBS worker with ingest monitoring and auto-actions — implemented but never spawned
apps/api/src/services/bootstrap_provider.rsInjects the decrypted obs credentials block into the overlay WebSocket bootstrap
apps/api/src/dispatch.rsAutomation OBS action → Redis lumio:obs:action:{account_id}
crates/lo-obs-remote/Server-side ObsRemoteClient, IngestMonitor, ObsRemoteStatus
crates/lo-obs/Rust type definitions mirroring the Browser Source API. Currently has no dependents; the browser-side implementation lives in @lumio/obs.
shared/obs/src/client.tsTyped wrapper around window.obsstudio
shared/obs/src/ws-client.tsObsWebSocketClient — typed obs-websocket v5 client, used by the overlay
shared/obs/src/types.tsOBSStatus, OBSScene, OBSTransition, OBSEventType, OBSControlLevel
apps/web/src/hooks/use-obs-local-connect.tsBrowser-side obs-websocket connection for overlays
apps/web/src/hooks/use-obs-websocket.tshandleObsRelayAction relay dispatcher (plus an unused useObsWebSocket hook)
apps/api/src/crypto.rsAES-256-GCM password encryption/decryption
apps/web/src/contexts/obs-browser-source-context.tsxObsBrowserSourceProvider + useObsBrowserSource hook
apps/web/src/app/(overlay)/overlay/[key]/overlay-client.tsxMounts ObsBrowserSourceProvider, connects to local OBS, routes obs:action
apps/web/src/components/overlay/overlay-preview.tsxMounts ObsBrowserSourceProvider for the editor preview
apps/web/src/app/(main)/(app)/dashboard/obs/layout.tsxFeatureRouteGate feature="feature:obs_remote" on the whole segment
apps/web/src/app/(main)/(app)/dashboard/obs/obs-dashboard.tsxOBS Remote page (scene grid + stream/recording controls)
apps/web/src/app/(popout)/popout/obs/page.tsxCompact OBS Remote popout (SSR-gated on feature:obs_remote, popout-session aware). NOT a Browser Source.
apps/web/src/app/(main)/(app)/dashboard/connections/obs-config.tsxPort / password / remote-host form