Events & hooks
React to what the host does, lifecycle events over SSE, hook commands and webhooks, per-app prep/undo, for notifications, DND toggles, Home Assistant, and more.
The host emits a lifecycle event for the things you'd want to react to: a client connects or disconnects, a stream starts or stops, a pairing request arrives, a virtual display is created, the library changes, the host starts or shuts down. Two ways to consume them:
- Hooks, zero-code: entries in
~/.config/slipstream/hooks.jsonrun a command or POST a webhook when a matching event fires. This covers the common automation: Do-Not-Disturb during a stream, a phone notification on a pairing request, pausing downloads while playing. - The event stream, code:
GET /api/v1/eventson the management API is a standard Server-Sent Events stream of the same events, for scripts and integrations that want to decide things (e.g. auto-approve pairing from a known subnet by calling the approve endpoint).
Hooks observe, they can never veto or delay a connection, a stream, or a pairing decision, and nothing you configure here runs anywhere near the streaming path.
The events
| Kind | Fires when | Carries |
|---|---|---|
client.connected / client.disconnected | a client session is admitted / goes away | device name, cert fingerprint, plane (native/gamestream); disconnect adds reason: quit (user stop), timeout (vanished), error |
session.started / session.ended | an A/V session registers / ends | session id, client label, mode (3840x2160@120), HDR |
stream.started / stream.stopped | video actually starts / stops | mode, HDR, client name, launched app id/title (when one was requested), plane |
game.running | a launched game's own process is seen running (not merely its launcher) | app id, title, store, client, plane |
game.exited | a launched game is gone | the same, plus reason: exited (the player quit it) or terminated (the host closed it, per your session⇄game settings) |
pairing.pending | an unpaired device knocks (once per device, not per retry) | device name, fingerprint, plane |
pairing.completed / pairing.denied | a pairing is approved+stored / denied | device name, fingerprint, plane |
display.created / display.released | a virtual display is minted / kept displays are released | backend + mode / count |
library.changed | the game library is mutated | source: manual, or the provider id that reconciled (PUT /api/v1/library/provider/{p}) |
update.available | a verified manifest announces a release newer than the running host, once per discovered version, not on every check | version, channel (stable/canary), and this host's install kind (apt, rpm, sysext, ...) |
update.applied | the new binary's first start after a successful update | from, to |
plugins.changed | a plugin's registration changes (registered, restarted, deregistered, or its lease expired) | plugin id |
store.changed | an install or uninstall finished, or a plugin catalog was refreshed | none, re-read GET /api/v1/store/catalog / .../installed |
host.started / host.stopping | the serve planes come up / wind down | version, whether GameStream is enabled |
Every event is a small JSON document with a monotonic seq, a ts_ms timestamp, a schema
version (additive-only, fields get added, never renamed), and the fields above. Example:
{ "seq": 42, "ts_ms": 1784227449526, "schema": 1,
"kind": "stream.started",
"stream": { "mode": "2560x1440@120", "hdr": true,
"client": "Living Room TV", "app": "steam:570", "plane": "native" } }Hooks: hooks.json
Create ~/.config/slipstream/hooks.json, or PUT
the same document to /api/v1/hooks from a script, changes apply immediately, no restart:
{
"hooks": [
{ "on": "stream.started", "run": "/home/me/.config/slipstream/scripts/on-stream.sh" },
{ "on": "stream.stopped", "run": "/home/me/.config/slipstream/scripts/off-stream.sh" },
{ "on": "client.connected", "filter": { "client": "Living Room TV" },
"run": "kscreen-doctor output.HDMI-A-1.mode.3840x2160@60" },
{ "on": "pairing.pending",
"webhook": "https://ha.local/api/webhook/slipstream",
"hmac_secret_file": "/home/me/.config/slipstream/webhook-secret" }
]
}Each entry:
| Field | Meaning |
|---|---|
on | Which events fire it: an exact kind (stream.started) or a domain.* prefix (pairing.*). |
run | A shell command (sh -c on Linux). Gets the event JSON on stdin and flat PF_EVENT_* env vars. |
webhook | A URL the event JSON is POSTed to. TLS-verified, redirects are never followed, no Slipstream credentials attached. |
filter | Optional exact-match constraints: client (device name), fingerprint, plane (native/gamestream), app. All present fields must match. |
timeout_s | Command timeout (default 30, max 600), on expiry the whole process group is killed. |
debounce_ms | Minimum interval between firings of this hook (0 = every event). |
hmac_secret_file | File with a secret; the webhook gains X-Slipstream-Signature: sha256=<hex HMAC-SHA256 of the body> so your receiver can authenticate the host. |
What the host refuses
The document is validated as a whole, and one bad entry disables every hook, the host logs
hooks.json invalid, hooks disabled until fixed and runs none of them until you correct it. The
rules:
- An entry needs a non-empty
on, plusrunand/orwebhook. webhookmust be anhttp(s)://URL, and must not point at loopback,localhostor a link-local address (which is also what blocks the cloud metadata endpoint). A receiver on this same machine is what aruncommand is for. Ordinary LAN addresses,192.168.x.x, a ULA, a hostname, are fine, so Home Assistant on another box on your network works as written.timeout_smust be 1-600.- If
hmac_secret_fileis set but unreadable, the host skips that POST rather than sending it unsigned.
Check the log after editing the file: journalctl --user -u slipstream-host, or the web
console's Logs page.
A run command's shell one-liner vocabulary, the event flattened to env, values sanitized:
#!/bin/sh
# PF_EVENT_KIND=stream.started PF_EVENT_SEQ=42
# PF_EVENT_STREAM_MODE=2560x1440@120 PF_EVENT_STREAM_HDR=true
# PF_EVENT_STREAM_CLIENT='Living Room TV' PF_EVENT_STREAM_APP=steam:570
# PF_EVENT_STREAM_PLANE=native PF_EVENT_JSON='{...the whole event...}'
[ "$PF_EVENT_KIND" = stream.started ] && makoctl mode -a do-not-disturbRicher payloads (and the full document) are on stdin, jq away.
Verify a signed webhook (Python):
import hmac, hashlib
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(request.headers["X-Slipstream-Signature"], expected)Rules of the road: hooks are fire-and-forget and bounded, at most 8 in flight (extra
firings are dropped with a log line, never queued), and a command that outlives its timeout is
killed. Hook commands run as the host user, so hooks.json is operator-privileged config. On
Linux, when a command starts with an absolute path to a script, the host checks that file is
owned by you (or root) and not group/world-writable, and refuses to run it, loudly, in the log,
if it isn't. Write the full path (/home/me/.config/slipstream/scripts/on-stream.sh, not ~/...) if
you want that check: the shell expands ~ and looks up PATH names like makoctl only afterwards,
so those are never checked.
The two simplest cases also exist as plain host.env settings, no
hooks.json needed: SLIPSTREAM_ON_CONNECT_CMD and SLIPSTREAM_ON_DISCONNECT_CMD.
Per-app prep/undo
For per-title setup (HDR toggle, MangoHud, a VRR tweak), attach prep steps to a GameStream
apps.json entry or to a custom library entry, each
do runs before the title launches
(synchronously, the launch waits), each undo runs at session end in reverse order,
best-effort, even if the session crashed:
{ "id": 2, "title": "Steam", "compositor": "gamescope", "cmd": "steam -gamepadui",
"prep": [
{ "do": "~/bin/hdr on", "undo": "~/bin/hdr off" },
{ "do": "pactl set-default-sink game_sink", "undo": "pactl set-default-sink desk_sink" }
] }A do that fails logs, keeps going, and its own undo is skipped (it never took effect).
Reacting to a game, not a stream
stream.stopped tells you the stream ended; game.exited tells you the game did. They are
often the same moment, but not always, a desktop stream has no game at all, and a stream can
outlive its game if you turned off "end the session when the game exits".
If you have been polling the host to work out when a game finished, you don't need to any more:
{ "hooks": [
{ "on": "game.running", "run": "/home/me/.config/slipstream/scripts/game-up.sh" },
{ "on": "game.exited", "run": "/home/me/.config/slipstream/scripts/game-down.sh" }
] }Both carry the title in PF_EVENT_GAME_TITLE / PF_EVENT_GAME_APP, and game.exited adds
PF_EVENT_REASON so a script can tell "the player quit" (exited) from "the host closed it"
(terminated), worth checking before you, say, power the TV off.
Ending the session yourself when a game exits needs no script at all: it is the default behavior, on the console's Virtual displays page under When a game or a session ends.
The event stream (GET /api/v1/events)
For code, subscribe to the SSE stream on the management API (loopback + bearer token, the same credentials as the rest of the admin surface):
. ~/.config/slipstream/mgmt-token # sets SLIPSTREAM_MGMT_TOKEN
curl -Nk -H "Authorization: Bearer $SLIPSTREAM_MGMT_TOKEN" \
"https://127.0.0.1:47990/api/v1/events?kinds=pairing.*,stream.*"The token file holds SLIPSTREAM_MGMT_TOKEN=<token>, not a bare token, so it can be sourced (or
handed to a systemd unit as an EnvironmentFile), cat it straight into the header and every
request comes back 401. The runner's plugin-token file has the same shape.
- Frames carry
id:(the event'sseq),event:(the kind),data:(the event JSON). - Reconnect with the standard
Last-Event-IDheader (or?since=<seq>) and the host replays what you missed from its in-memory ring (~1024 events); if you fell off the ring you get oneevent: droppedframe first, resync from the REST snapshots (/status,/clients, ...). ?kinds=filters server-side: exact kinds ordomain.*prefixes, comma-separated.
Scripts, plugins, and the runner
For anything beyond a curl one-liner there is @slipstream/host, the TypeScript SDK
(sdk/ in the repo): typed events with automatic reconnect/resume, the REST surface, and a
plugin convention (slipstream-plugin-*). Its runner (slipstream-scripting) supervises a
directory of scripts and installed plugins as one service: crash-restarts with backoff, and a
systemctl stop that interrupts plugins structurally so their cleanup runs. See the SDK README
for the five-line quickstart and unit templates.
For ready-made plugins, sync your ROM collection into the game library, or
hand a USB device on the couch to the host, see Plugins. Install one from the web
console's Plugins page (Browse -> pick -> confirm; the host installs it and restarts the runner),
or from a terminal with slipstream-host plugins add <name> followed by
slipstream-host plugins enable.
The canonical "decide, don't just observe" pattern, approve pairing from your phone: watch
pairing.pending, send yourself a notification, and call
POST /api/v1/native/pending/{id}/approve when you tap yes. The full API is documented at
/api/docs on your host.
A unit under the runner auto-connects with the host's scoped plugin token, which covers the everyday surface (status, library, sessions, events) but deliberately not hook registration, pairing administration, the plugin store (
/api/v1/store..., reads included), the update endpoints (/api/v1/update...), or another plugin's UI credential, so a plugin defect can't admit new devices, install code, or trigger an update. Those routes answer 403 on the plugin token. A script that should administer pairing (like the approval pattern above) opts into the full-admin credential explicitly: setSLIPSTREAM_MGMT_TOKENon the unit (e.g. asystemctl --user edit slipstream-scriptingdrop-in) or pass{ token }toconnect().
Recipe: full controller passthrough (VirtualHere)
To get a controller's native features on the host, DualSense gyro, touchpad, adaptive triggers, USB rumble, or to use a device no emulation can stand in for, like a racing wheel or a HOTAS, hand the physical device from the couch to the host over VirtualHere (USB-over-IP) while you play.
The two sides. VirtualHere is a server/client pair, and you run both: the server on the couch
(where the device is plugged in) shares it, and the client on the host mounts it. The client's
-t flag is a one-shot IPC to the already-running client, -t LIST prints every visible device
with its address (server.port, e.g. couch-deck.11), -t "USE,<addr>" mounts it, and
-t "STOP USING,<addr>" hands it back.
Zero-code: two hooks
Bracket it on the stream with two hooks:
{
"hooks": [
{ "on": "stream.started", "run": "vhclientx86_64 -t \"USE,couch-deck.11\"" },
{ "on": "stream.stopped", "run": "vhclientx86_64 -t \"STOP USING,couch-deck.11\"" }
]
}couch-deck.11 is the device's address from vhclientx86_64 -t LIST.
The address is hard-coded, so it can change when the couch reboots or the device moves port. An
abnormal stream exit can also skip the stream.stopped hook. The
virtualhere-dualsense.ts
SDK example resolves devices by name and performs cleanup through the scripting runner.
VirtualHere is a commercial product, sold separately by VirtualHere Pty. Ltd., free for one shared device, licensed beyond that. Slipstream is not affiliated with it.
