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.htmlmanifest.json
Every .streamoos overlay needs a manifest:
{
"id": "a1b2c3d4-0000-0000-0000-000000000001",
"name": "My Timer",
"version": "1.0",
"htmlPath": "index.html",
"createdAt": "2026-01-01T00:00:00.000Z"
}| Field | Type | Description |
|---|---|---|
id | UUID string | A unique identifier. Generate one at uuidgenerator.net or with crypto.randomUUID() in your browser console. |
name | string (1--100 chars) | Display name shown in the dashboard overlay manager. |
version | "1.0" | Schema version. Always "1.0" for now. |
htmlPath | string | Relative path to the entry HTML file. Must end in .html, must not start with / or contain ... |
createdAt | ISO 8601 date | Creation timestamp. |
index.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:
<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:
<script src="../shared/websocket-client.js"></script>Creating a client
const client = new StreamooSClient("ws://127.0.0.1:8765");The constructor accepts an optional second argument for advanced options:
| Option | Default | Description |
|---|---|---|
reconnectBaseDelayMs | 1000 | Initial delay before the first reconnection attempt. |
reconnectMaxDelayMs | 15000 | Maximum delay between reconnection attempts. |
reconnectJitterMs | 250 | Random jitter added to each delay to prevent thundering herd. |
connectTimeoutMs | 5000 | How long to wait for the initial handshake before giving up. |
autoConnect | true | Set to false to connect manually with client.connect(). |
debug | false | When true, the client logs lifecycle and message events with the [StreamooS] prefix. Off keeps overlays quiet in production. |
Listening for updates
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:
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:
| Option | Type | Description |
|---|---|---|
feature | string | When set, onUpdate only fires for messages whose feature matches. |
url | string | Override the WebSocket URL. Defaults to ws://127.0.0.1:8765. |
debug | boolean | Enable verbose logging. Defaults to false. |
onUpdate | function | (data, msg) => void - receives the unwrapped data payload plus the raw message. |
onResponse | function | Server response to a command you sent. |
onError | function | Server-side error notification. |
onConnected | function | Called once the WebSocket handshake completes. |
onDisconnected | function | Called on close, with { willReconnect }. |
Sending commands
client.sendCommand("timer", "start", { minutes: 5, seconds: 0 });Parameters: sendCommand(feature, action, data). The data argument is optional and defaults to {}.
Lifecycle events
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
client.disconnect(); // Close and stop reconnecting
client.reconnectNow(); // Force an immediate reconnection attemptComposite 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:
await requestOverlaySlot(myConfiguredPosition, durationMs);
// now safe to show the item; outside composite mode this resolves immediatelyDeclare that in your builtin manifest.json too, so a composite preset knows to arbitrate it:
{ "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
| Action | Parameters | Description |
|---|---|---|
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:
{
running: true, // boolean
durationSeconds: 300, // total duration
remainingSeconds: 120, // seconds left
endAtMs: 1760000000000 // epoch ms when timer reaches zero (null if paused)
}khinsider_radio
| Action | Parameters | Description |
|---|---|---|
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:
{
active: true,
isPlaying: true,
isShuffleEnabled: false,
volume: 75,
title: "Main Theme",
game: "Celeste",
coverUrl: "https://...",
currentTimeSeconds: 42,
durationSeconds: 180,
history: [{ title, game, coverUrl }, ...]
}smtc
| Action | Parameters | Description |
|---|---|---|
play | -- | Resume system media. |
pause | -- | Pause system media. |
next | -- | Next track. |
previous | -- | Previous track. |
get_state | -- | Request current state. |
Update data shape:
{
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
| Action | Parameters | Description |
|---|---|---|
status | -- | Get connection status. |
get_profile | -- | Get streamer profile. |
Update data shape:
{
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:
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-rightTesting locally
- Start StreamooS in development mode:
pnpm dev - Place your overlay folder inside
app/overlays/during development (e.g.,app/overlays/my-timer/) - Open in a browser at
http://127.0.0.1:8766/my-timer/index.html(the embedded asset server) or directly viafile://path - 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"
- 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:
- Make sure your folder contains a valid
manifest.jsonat the root - Name the folder with the
.streamoosextension:my-timer.streamoos/ - 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
idmust be a valid UUID v4namemust be between 1 and 100 charactersversionmust be exactly"1.0"htmlPathmust end in.html, must not contain.., and must not start with/createdAtmust 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(notlocalhost, notwss://)
Overlay connects but receives no updates
Send a
get_statecommand after connecting to request the current state:jsclient.onConnected(() => { client.sendCommand("timer", "get_state"); });Verify you are filtering
msg.featurecorrectly in youronUpdatehandlerMake 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:
| Origin | Context |
|---|---|
null | OBS Browser Source (local file) |
file:// | Electron production |
http://127.0.0.1:5173 | Vite dev server |
http://127.0.0.1:8766 | Embedded 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:
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)
