PunktfunkPunktfunk
Your host

Events & hooks

Run a command or call a webhook when the host does something, prepare the host for one game, or read the host's event stream from a script.

Make the host do something when a stream starts, a device knocks or a game exits: turn on Do-Not-Disturb, send a phone notification, pause downloads, switch the TV's HDR. The host publishes an event for each of these, and you react with:

  • Hooks: run a command or call a webhook. Set them on the console's Automation page or in hooks.json.
  • Per-app prep/undo: commands around one title's launch.
  • The event stream: GET /api/v1/events, for scripts and plugins.

Hooks only observe. They can't veto or delay a connection, a stream or a pairing, and they never run in the streaming path. The one exception is a launch: a hook with hold set on game.launching runs before the game starts, and the game waits for it.

The events

KindFires whenCarries
client.connected / client.disconnecteda device connects / goes awayname, fingerprint, plane (native, gamestream, or web for a browser), the profile it plays as (id, display_name; not on gamestream); disconnect adds reason: quit, timeout or error
session.started / session.endeda session starts / endssession id, client, fingerprint, plane, mode (3840x2160@120), HDR, profile. session.ended adds a summary (duration, codec, bitrate span, frames, bring-up time, and ended: local, game_exited, host_ended, host_error, lost, stopped_by_operator or stolen), the same shape as GET /api/v1/session/last
stream.started / stream.stoppedvideo starts / stopsmode, HDR, client, fingerprint, launched app, plane, profile
game.launchingthe host is about to start a launched game; not when it picks up one still runningapp id, title, store, client, fingerprint, plane, preset, profile
game.runninga launched game's own process runs (not just its launcher)app id, title, store, client, fingerprint, plane, profile
game.windowthe game's window reaches the screen, often 5–40 s after game.runningthe same, plus the window's title and app_id
game.exiteda launched game is gonethe same, plus reason: exited (the player quit) or terminated (the host closed it, per these settings)
pairing.pendingan unpaired device knocks, once per devicename, fingerprint, plane (native for a browser too: it pairs into the same store)
pairing.completed / pairing.denieda pairing is approved / deniedname, fingerprint, plane
access.granted / access.changedyou pick a device's access when pairing / edit it laterdevice, grants bits, expires_unix (absent: no expiry)
access.expireda streaming device's access runs outdevice
display.created / display.releaseda virtual display is created / kept displays are releasedbackend and mode / count
library.changedthe game library changessource: manual or the provider id
downloads.changeda title's download starts, pauses or ends, or its files are removedapp, title, state (queued, paused, done, failed, cancelled, removed, …)
update.availablea newer release is found, once per versionversion, channel, install kind (apt, windows-installer, …)
update.appliedthe updated host first startsfrom, to
action.invokeda host power action is accepted, or failsid (power.sleep, power.reboot, power.shutdown, host.restart), device (absent for the console), outcome
plugins.changeda plugin registers, restarts or goes awayplugin id
store.changeda plugin install or uninstall finished, or a catalog refreshednothing: re-read the store
settings.changedhost settings changed in the consolethe setting ids
host.started / host.stoppingthe host comes up / shuts downversion, whether GameStream is on

Each event is one JSON object with a rising seq, a ts_ms timestamp and a schema version. Fields are only ever added, never renamed.

{ "seq": 42, "ts_ms": 1784227449526, "schema": 1,
  "kind": "stream.started",
  "stream": { "mode": "2560x1440@120", "hdr": true,
              "client": "Living Room TV", "fingerprint": "9f86d081…",
              "app": "steam:570", "plane": "native",
              "preset": { "id": "3f9a0c11e2b4", "name": "Docked" } } }

preset names the settings preset the device streamed with; it is absent when the device used its plain settings, and on Moonlight. profile names who plays (id, display_name); it is absent on Moonlight.

Hooks

In the console, open Automation → Add hook, pick the event under When (a trailing .* matches a whole domain) and Run a command or Call a webhook under Then. Only for a specific client or game narrows it. Save asks for the console password.

The console writes ~/.config/punktfunk/hooks.json (Windows: %ProgramData%\punktfunk\hooks.json). You can edit that file by hand or PUT it to /api/v1/hooks; changes apply from the next event.

{
  "hooks": [
    { "on": "stream.started", "run": "/home/me/.config/punktfunk/scripts/on-stream.sh" },
    { "on": "client.connected",
      "filter": { "fingerprint": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08" },
      "run": "kscreen-doctor output.HDMI-A-1.mode.3840x2160@60" },
    { "on": "pairing.pending",
      "webhook": "https://ha.local/api/webhook/punktfunk",
      "hmac_secret_file": "/home/me/.config/punktfunk/webhook-secret" }
  ]
}
FieldMeaning
onAn event kind (stream.started) or a domain (pairing.*). Required, with run, webhook or both.
runA shell command.
webhookA URL the event JSON is POSTed to.
filterOptional exact matches, all of which must hold: fingerprint (the device), client (its name), plane (native, gamestream, web), app, preset (its name or id), profile (its id).
timeout_sSeconds before a command is killed with everything it started. 1–600, default 30.
debounce_msMinimum gap between firings of this hook. Default 0.
holdOnly with on: game.launching: the game waits for this hook, up to timeout_s.
hmac_secret_fileSigns webhooks with X-Punktfunk-Signature: sha256=<hex HMAC-SHA256 of the body>.

Filter on fingerprint, not client: names aren't unique and change on rename. The console's device picker stores the fingerprint; GET /api/v1/clients lists both.

Commands

A command runs as the host user (sh -c on Linux) with the event JSON on stdin and the event flattened into PF_EVENT_* variables:

#!/bin/sh
# PF_EVENT_KIND=stream.started  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_JSON='{…}'
[ "$PF_EVENT_KIND" = stream.started ] && makoctl mode -a do-not-disturb
  • Windows: the host service runs the command as the signed-in user, never as SYSTEM, with no stdin and no PF_* variables. The event arrives as a JSON file path, appended as the last argument.
  • Linux: every absolute path in the command, and each directory above it, must be owned by you or root and not group- or world-writable, or the host refuses the hook in its log. ~/… paths and bare names like makoctl aren't checked.
  • At most 8 hooks run at once; more are dropped and logged, never queued.

PUNKTFUNK_ON_CONNECT_CMD and PUNKTFUNK_ON_DISCONNECT_CMD in host.env run a command on client.connected / client.disconnected without a hooks.json.

Webhooks

The host POSTs the event JSON with verified TLS, follows no redirects and sends no Punktfunk credentials. A webhook can't point at localhost, a loopback or a link-local address; use run for a receiver on the host itself. LAN addresses and hostnames are fine.

With hmac_secret_file set, the host signs every POST, and skips it if it can't read the file. chmod 600 the file. Check the signature on the receiver:

import hmac, hashlib
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
ok = hmac.compare_digest(request.headers["X-Punktfunk-Signature"], expected)

Hold the launch

A hook on game.launching with hold set runs before the game starts, and the launch waits for it: the command until it exits, the webhook until it answers. Each gets timeout_s; past it, or on a failure, the game starts anyway and the log says why. The device shows its launch screen meanwhile. debounce_ms doesn't apply, and a held hook never runs a second time on its own.

{ "on": "game.launching", "filter": { "preset": "Docked" }, "hold": true, "timeout_s": 20,
  "run": "/home/me/.config/punktfunk/scripts/tv-profile.sh" }

Plugins hold the same stage: see Writing plugins.

A hook that doesn't fire

One invalid entry disables every hook: the log says hooks.json invalid — hooks disabled until fixed and names the entry. Read it on the console's Troubleshooting page or with journalctl --user -u punktfunk-host. Log lines name a hook by its program or the webhook's scheme://host, never its arguments or URL path. A client filter that never matches is logged with the name the event carried.

Per-app prep/undo

Attach prep steps to a custom library entry in library.json, or to a GameStream apps.json entry. Each do runs before the title launches, and the launch waits for it (up to 30 s per step). Each undo runs when the session ends, in reverse order, even after a crash. A do that fails is logged and its undo skipped. The console's Library form keeps prep steps but can't edit them.

Every step gets the session's mode: PF_STREAM_WIDTH, PF_STREAM_HEIGHT, PF_STREAM_REFRESH, PF_STREAM_HDR (1 / 0), plus PF_APP_ID (a Punktfunk client's launch) or PF_APP_TITLE (Moonlight), and PF_PRESET_ID / PF_PRESET_NAME when the device streamed with a preset. undo sees the same values as its do, so one entry serves every device. A Windows host service can't pass these variables to its steps.

An apps.json entry; the prep array is the same in library.json:

{ "id": 2, "title": "Cyberpunk 2077", "cmd": "steam -applaunch 1091500",
  "prep": [
    { "do":   "/home/me/.config/punktfunk/scripts/mode.sh do",
      "undo": "/home/me/.config/punktfunk/scripts/mode.sh undo" }
  ] }
#!/bin/sh
# mode.sh: cap the game at the device's refresh and light HDR only when negotiated.
set -eu   # fails loudly if a variable is missing, which also skips the undo
CONF="${XDG_CONFIG_HOME:-$HOME/.config}/MangoHud/MangoHud.conf"
case "${1:-}" in
do)   cp -f "$CONF" "$CONF.pf-bak"
      printf 'fps_limit=%s\n' "$PF_STREAM_REFRESH" >>"$CONF"
      [ "$PF_STREAM_HDR" = 1 ] && kscreen-doctor output.HDMI-A-1.hdr.enable || true ;;
undo) mv -f "$CONF.pf-bak" "$CONF"
      [ "$PF_STREAM_HDR" = 1 ] && kscreen-doctor output.HDMI-A-1.hdr.disable || true ;;
esac

A launch on its own workspace

On Hyprland, sway and niri, a library launch opens on an empty workspace of the streamed screen instead of on top of your desk, and the host switches back when the game ends. A reconnect returns to that game's workspace. KWin, GNOME, gamescope and Windows open it where the screen already is.

Set it per entry in library.json, beside prep. The keys act once, on the game's first window:

{ "title": "Hades", "on_window": {
    "workspace": "own", "focus": true, "fullscreen": false, "move_to_stream_output": true
} }
KeyDefaultDoes
workspaceownown for an empty workspace, current for whatever the screen shows. The host-wide default is launch_workspace in display-settings.json.
focustrueRaises the window.
fullscreenfalseMakes it full-screen. Most games set their own mode.
move_to_stream_outputtrueMoves it to the streamed screen if it opened on one of your monitors.

A compositor that refuses a step logs one line; the game still opens.

Reacting to a game, not a stream

stream.stopped means the stream ended; game.exited means the game did. They differ on a desktop stream with no game, or when the session outlives its game. Dim the lights on game.running, when the game's process runs; send "ready" on game.window, when its window is on screen, which can be half a minute later on a cold Proton prefix. game.window doesn't fire on KWin without its window-list permission or on GNOME without the Punktfunk extension.

Scripts read PF_EVENT_GAME_TITLE, PF_EVENT_GAME_APP and, on game.exited, PF_EVENT_REASON: exited when the player quit, terminated when the host closed it.

The event stream

For a shell script, punktfunk-host ctl watch prints one JSON object per line, and resumes and reconnects by itself:

punktfunk-host ctl watch --kinds pairing.pending,stream.'*'

A {"kind":"ctl.resync"} line means events were missed: re-read the state you need.

To read Server-Sent Events directly, call the management API on the host with its token:

. ~/.config/punktfunk/mgmt-token   # sets PUNKTFUNK_MGMT_TOKEN
curl -Nk -H "Authorization: Bearer $PUNKTFUNK_MGMT_TOKEN" \
  "https://127.0.0.1:47990/api/v1/events?kinds=pairing.*,stream.*"

The token file is PUNKTFUNK_MGMT_TOKEN=<token>, so source it; cat into the header gets 401.

  • Each frame carries id: (the seq), event: (the kind) and data: (the event JSON).
  • ?kinds= filters by kind or domain.*, comma-separated.
  • Reconnect with Last-Event-ID or ?since=<seq> to replay what you missed from the last ~1024 events. If they're gone, an event: dropped frame comes first: re-read /status, /clients and the rest.
  • Without a cursor the stream replays what it holds, then sends event: live. Stay quiet until it arrives.
  • At most 32 streams at once; more get 503.

Scripts and plugins

Ready-made plugins sync ROMs or a Playnite library, or hand a USB device to the host: see Plugins. To write your own, see Writing plugins and the TypeScript client. The full API is at /api/docs on your host.

A plugin under the runner gets a limited token. It can't register hooks, administer pairing, use the plugin store or the update endpoints, or read the logs; those routes answer 403. A script that should approve pairings, say by watching pairing.pending and calling POST /api/v1/native/pending/{id}/approve when you tap yes on your phone, needs the full token: set PUNKTFUNK_MGMT_TOKEN on its unit.

Recipe: full controller passthrough (VirtualHere)

To give the host the real device (DualSense gyro and adaptive triggers, a racing wheel, a HOTAS), hand it over USB with VirtualHere, a commercial product sold separately.

  1. Use the VirtualHere plugin. It finds the device by name, hands it back if anything crashes, and tells you which side is misconfigured.
  2. On the couch client, turn off Forward controllers (Client settings). Otherwise games see two controllers, and on Linux and Windows VirtualHere can't take a pad the client holds.

Without the plugin, two hooks do the basic job. The address comes from vhclientx86_64 -t LIST; it changes when the couch reboots, and an abnormal end leaves the device on the host:

{ "hooks": [
    { "on": "stream.started", "run": "vhclientx86_64 -t \"USE,couch-deck.11\"" },
    { "on": "stream.stopped", "run": "vhclientx86_64 -t \"STOP USING,couch-deck.11\"" }
] }

On this page