Skip to content

Overlay SDK ​

Build custom OBS overlays that react to StreamooS in real time -- timer countdowns, now-playing banners, alert animations, or anything you can express in HTML/CSS/JS.

What is a custom overlay? ​

A custom overlay is a small web page (HTML + CSS + JS) that StreamooS serves to OBS as a Browser Source. It connects to the local WebSocket server at ws://127.0.0.1:8765 and receives live state updates (timer ticks, track changes, Twitch events...). You can also send commands back to control features from within the overlay.

Custom overlays are packaged as .streamoos directories and imported through the StreamooS dashboard.

Your first overlay ​

Let's build a minimal timer display in under 20 lines.

File structure ​

my-timer.streamoos/
  manifest.json
  index.html

manifest.json ​

Every .streamoos overlay needs a manifest:

json
{
  "id": "a1b2c3d4-0000-0000-0000-000000000001",
  "name": "My Timer",
  "version": "1.0",
  "htmlPath": "index.html",
  "createdAt": "2026-01-01T00:00:00.000Z"
}
FieldTypeDescription
idUUID stringA unique identifier. Generate one at uuidgenerator.net or with crypto.randomUUID() in your browser console.
namestring (1--100 chars)Display name shown in the dashboard overlay manager.
version"1.0"Schema version. Always "1.0" for now.
htmlPathstringRelative path to the entry HTML file. Must end in .html, must not start with / or contain ...
createdAtISO 8601 dateCreation timestamp.

index.html ​

html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <style>
      body {
        background: transparent;
        margin: 0;
        font-family: monospace;
        color: white;
        font-size: 48px;
      }
    </style>
  </head>
  <body>
    <div id="display">--:--</div>

    <script src="https://127.0.0.1:8766/shared/websocket-client.js"></script>
    <script>
      const client = new StreamooSClient("ws://127.0.0.1:8765");

      client.onUpdate((msg) => {
        if (msg.feature !== "timer") return;
        const s = msg.data.remainingSeconds;
        const mm = String(Math.floor(s / 60)).padStart(2, "0");
        const ss = String(s % 60).padStart(2, "0");
        document.getElementById("display").textContent = mm + ":" + ss;
      });

      client.onConnected(() => {
        client.sendCommand("timer", "get_state");
      });
    </script>
  </body>
</html>

That is it. When StreamooS is running and a timer is active, this overlay shows a live countdown.

Connecting to the SDK ​

Including the client ​

The shared WebSocket client is available from the StreamooS asset server:

html
<script src="https://127.0.0.1:8766/shared/websocket-client.js"></script>

If your overlay is placed directly inside the app/overlays/ source folder during development, you can also use a relative path:

html
<script src="../shared/websocket-client.js"></script>

Creating a client ​

js
const client = new StreamooSClient("ws://127.0.0.1:8765");

The constructor accepts an optional second argument for advanced options:

OptionDefaultDescription
reconnectBaseDelayMs1000Initial delay before the first reconnection attempt.
reconnectMaxDelayMs15000Maximum delay between reconnection attempts.
reconnectJitterMs250Random jitter added to each delay to prevent thundering herd.
connectTimeoutMs5000How long to wait for the initial handshake before giving up.
autoConnecttrueSet to false to connect manually with client.connect().
debugfalseWhen true, the client logs lifecycle and message events with the [StreamooS] prefix. Off keeps overlays quiet in production.

Listening for updates ​

js
const off = client.onUpdate((msg) => {
  // msg.type     === 'update'
  // msg.feature  === 'timer' | 'khinsider_radio' | 'smtc' | ...
  // msg.data     === feature-specific state object
});

// Detach later when you no longer need this handler
off();

Every on* registration returns an unsubscribe function. Call it to remove that single handler - there is no need to keep a reference to the original callback.

Updates are broadcast every time a feature's state changes. Filter by msg.feature to react only to the data you need.

One-call helper: bindOverlay() ​

For the common case "connect, filter to one feature, react to its state", bindOverlay() removes the boilerplate:

js
const client = bindOverlay({
  feature: "timer",
  onConnected: () => client.sendCommand("timer", "get_state"),
  onUpdate: (data) => {
    // `data` is already unwrapped (msg.data for the matching feature)
    document.getElementById("display").textContent = data.remainingSeconds;
  },
});

bindOverlay() returns the underlying StreamooSClient, so you can still call sendCommand(), disconnect(), etc. Available options:

OptionTypeDescription
featurestringWhen set, onUpdate only fires for messages whose feature matches.
urlstringOverride the WebSocket URL. Defaults to ws://127.0.0.1:8765.
debugbooleanEnable verbose logging. Defaults to false.
onUpdatefunction(data, msg) => void - receives the unwrapped data payload plus the raw message.
onResponsefunctionServer response to a command you sent.
onErrorfunctionServer-side error notification.
onConnectedfunctionCalled once the WebSocket handshake completes.
onDisconnectedfunctionCalled on close, with { willReconnect }.

Sending commands ​

js
client.sendCommand("timer", "start", { minutes: 5, seconds: 0 });

Parameters: sendCommand(feature, action, data). The data argument is optional and defaults to {}.

Lifecycle events ​

js
client.onConnected(() => {
  // WebSocket connection established
});

client.onDisconnected(({ willReconnect }) => {
  // willReconnect is true unless you called client.disconnect()
});

client.onResponse((msg) => {
  // Response to a command you sent (msg.ok, msg.data, msg.code)
});

client.onError((msg) => {
  // Server-side error
});

Manual control ​

js
client.disconnect(); // Close and stop reconnecting
client.reconnectNow(); // Force an immediate reconnection attempt

Composite mode ​

An overlay can also run embedded in a composite source (http://127.0.0.1:8766/overlays/composite/?preset=<id>), which stacks several overlays behind one OBS source. Nothing to opt into for a normal overlay - StreamooSClient detects the ?composite=1 query param the composite page appends to your src and transparently switches to proxy mode (no socket of its own, relays through the parent's one connection). Your overlay code and its own position setting work unchanged either way.

If your overlay shows a transient, event-driven item (an alert popup, not a persistent widget like a clock or a now-playing card), await a screen-position slot before rendering it, so it never overlaps another event overlay placed at the same position in someone's composite preset:

js
await requestOverlaySlot(myConfiguredPosition, durationMs);
// now safe to show the item; outside composite mode this resolves immediately

Declare that in your builtin manifest.json too, so a composite preset knows to arbitrate it:

json
{ "kind": "event" }

Omit kind (or set it to "permanent") for anything that isn't a transient popup - it never queues. Full design and current limits: doc/internal/adr/ADR-010-overlay-composite-mode.md.

Features and events reference ​

Every feature broadcasts update messages. Here are the available features and their actions:

timer ​

ActionParametersDescription
start{ minutes, seconds }Start (or resume) the timer.
stop--Pause the timer.
reset--Reset to zero.
get_state--Request current state (triggers an update).

Update data shape:

js
{
  running: true,            // boolean
  durationSeconds: 300,     // total duration
  remainingSeconds: 120,    // seconds left
  endAtMs: 1760000000000    // epoch ms when timer reaches zero (null if paused)
}

khinsider_radio ​

ActionParametersDescription
play--Resume playback.
pause--Pause playback.
next--Skip to the next track.
shuffle{ enabled } (optional)Toggle or set shuffle mode.
volume{ value } (0--100)Set playback volume.
get_state--Request current state.
activate--Activate the radio module.
deactivate--Deactivate the radio module.

Update data shape:

js
{
  active: true,
  isPlaying: true,
  isShuffleEnabled: false,
  volume: 75,
  title: "Main Theme",
  game: "Celeste",
  coverUrl: "https://...",
  currentTimeSeconds: 42,
  durationSeconds: 180,
  history: [{ title, game, coverUrl }, ...]
}

smtc ​

ActionParametersDescription
play--Resume system media.
pause--Pause system media.
next--Next track.
previous--Previous track.
get_state--Request current state.

Update data shape:

js
{
  connected: true,
  isPlaying: true,
  title: "Song Title",
  artist: "Artist",
  albumTitle: "Album",
  artworkUrl: "https://...",
  progressMs: 30000,
  durationMs: 240000,
  radioSource: "smtc",    // 'khinsider' | 'smtc'
  mediaApp: "spotify",    // app id detecte, "" si inconnu
  mediaAppLabel: "Spotify"
}

twitch ​

ActionParametersDescription
status--Get connection status.
get_profile--Get streamer profile.

Update data shape:

js
{
  active: true,
  connected: true,
  displayName: "StreamerName",
  avatarUrl: "https://..."
}

Overlay configuration ​

Each built-in overlay supports a set_overlay_config action that controls visibility, position, and theme. You can reuse this pattern in your own overlays to persist user preferences:

js
client.sendCommand("timer", "set_overlay_config", {
  enabled: true,
  position: "bottom-right",
  theme: "streamoos",
});

Available positions:

top-left       top-center       top-right
middle-left    center           middle-right
bottom-left    bottom-center    bottom-right

Testing locally ​

  1. Start StreamooS in development mode: pnpm dev
  2. Place your overlay folder inside app/overlays/ during development (e.g., app/overlays/my-timer/)
  3. Open in a browser at http://127.0.0.1:8766/my-timer/index.html (the embedded asset server) or directly via file:// path
  4. Add as OBS Browser Source:
    • Click + then Browser
    • Uncheck "Local file" and enter the URL or absolute file:// path
    • Resolution: 1920x1080 (or match your scene)
    • Enable "Refresh browser when scene becomes active"
  5. Iterate: edit your HTML/CSS/JS, then refresh the Browser Source in OBS (right-click the source, "Refresh")

Tip: Use your browser's DevTools (F12) to debug before moving to OBS. The WebSocket connection works identically in both environments.

Packaging as .streamoos ​

When your overlay is ready for distribution or import:

  1. Make sure your folder contains a valid manifest.json at the root
  2. Name the folder with the .streamoos extension: my-timer.streamoos/
  3. Import through the StreamooS dashboard: open the Overlay Manager page and use the import function

The dashboard reads the manifest, validates it, and copies the overlay to the app's userData directory. From there it appears in the overlay list and can be opened in OBS.

Manifest validation rules ​

  • id must be a valid UUID v4
  • name must be between 1 and 100 characters
  • version must be exactly "1.0"
  • htmlPath must end in .html, must not contain .., and must not start with /
  • createdAt must be a valid ISO 8601 date string

If any field is invalid, the import will fail with a validation error shown in the dashboard.

Troubleshooting ​

Overlay shows "Connecting..." forever ​

  • Verify StreamooS is running and the WebSocket server is active on port 8765
  • Check the browser console (F12) for connection errors
  • Make sure the URL is ws://127.0.0.1:8765 (not localhost, not wss://)

Overlay connects but receives no updates ​

  • Send a get_state command after connecting to request the current state:

    js
    client.onConnected(() => {
      client.sendCommand("timer", "get_state");
    });
  • Verify you are filtering msg.feature correctly in your onUpdate handler

  • Make sure the feature is actually active (e.g., a timer must be started, some media must actually be playing)

CORS or origin errors ​

The WebSocket server only accepts connections from these origins:

OriginContext
nullOBS Browser Source (local file)
file://Electron production
http://127.0.0.1:5173Vite dev server
http://127.0.0.1:8766Embedded asset server

If you are testing from another origin (e.g., http://localhost:3000), the connection will be rejected with HTTP 403.

Transparent background not working in OBS ​

Make sure your CSS includes:

css
body {
  background: transparent;
}

And in OBS, the Browser Source "Custom CSS" field should not override this.

Feature name not recognized ​

Feature names are case-sensitive and must match exactly:

timer -- khinsider_radio -- smtc -- twitch -- discord_rpc -- system

Script not loading ​

If using the shared client from the asset server, ensure you use https://127.0.0.1:8766 (HTTPS, port 8766), not HTTP. If testing via file://, use a relative path instead.

Next steps ​

  • Browse the WebSocket API reference for the full command matrix and state contracts
  • See OBS Overlays for details on themes, positions, and built-in overlay configuration
  • Check the built-in overlays in app/overlays/ for real-world examples (timer, KHInsider, SMTC, dock)

Released under the MIT License.