Skip to content

Bifrost Public API (/api/v1)

A key-authenticated REST API for external applications (automation scripts, assistants, etc.). A valid key grants full access to every device domain — lights, rooms (including scenes), media, power, remotes, and read-only sensors — there is no RBAC. Session-only UI config (floor plan, boards, provider management) is not exposed; use the web UI for those. The same key also unlocks the embedded MCP server at /mcp for natural-language control.

Authentication

Create a key in Settings → API keys. The full key (bfr_ + 64 hex chars) is shown exactly once at creation — only a SHA-256 hash is stored, so it cannot be recovered later. Revoking a key takes effect immediately.

Send the key as a Bearer token on every request:

Authorization: Bearer bfr_<your-key>

Missing or unknown keys get 401 Unauthorized on every endpoint.

curl -H "Authorization: Bearer $BIFROST_KEY" http://bifrost.local:3000/api/v1/lights

Data shapes

Light

{
  "id": "8b7f…",                 // Bifrost UUID — use this in all /lights/{id} calls
  "provider_id": "ab12…",        // provider-native identifier (informational)
  "provider": "hue",             // hue | govee | lifx | wled | tasmota | shelly | ha
  "name": "Desk lamp",
  "state": {  LightState  },
  "capabilities": {
    "dimmable": true,
    "color_rgb": true,
    "color_temperature": true,
    "hue_gamut": "C",            // A | B | C | null
    "effects": ["no_effect", "candle", "fire"], // supported dynamic effects; omitted when none
    "segments": 15              // addressable colour segments (Govee strips); omitted when none
  },
  "last_seen": "2026-06-11T12:00:00Z"
}

LightState

Sent in full on writes (it is a complete state, not a patch):

{
  "on": true,
  "brightness": 80.0,            // 0–100, null for non-dimmable
  "color": {                     // CIE xyY; null to leave color alone
    "x": 0.4573,
    "y": 0.41,
    "brightness": 1.0            // linear Y, 0.0–1.0
  },
  "color_temp_mirek": null,      // 153–500 (≈6500K–2000K); alternative to color
  "reachable": true,             // read-only; ignored on writes
  "effect": "candle"             // a name from capabilities.effects; "no_effect" clears. Omit to leave unchanged
}

Scene (full-state snapshot)

{
  "id": "f3c2…",
  "name": "Movie Night",
  "created_at": "2026-06-17T21:00:00Z",
  "lights": 3,                  // captured light entries
  "power": 1,                   // captured power-device entries
  "is_default": false,          // the single "Restore Home" preset (home scenes only)
  "room_id": "a1b2…",           // null = whole-home (Home Scene); set = Room Scene
  "room_name": "Living Room"    // null for a home scene
}

A scene is a snapshot of each captured light's full state (color or temperature or effect) plus each power device's on/off, restored verbatim. A Home Scene (room_id: null) captures the whole home; a Room Scene captures one room's effective members. Activating a scene re-applies exactly what was captured.

Endpoints

Lights

Method Path Description
GET /api/v1/lights All lights with current state
GET /api/v1/lights/{id} One light (404 if unknown)
PUT /api/v1/lights/{id}/state Set state; body is a full LightState
PUT /api/v1/lights/{id}/segments Set per-segment colours (strips with capabilities.segments)

PUT …/state responds 204 No Content on success, 404 for an unknown light, 502 if the provider could not be reached.

# Turn a light red at half brightness
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"on":true,"brightness":50,"color":{"x":0.675,"y":0.322,"brightness":1.0}}' \
  http://bifrost.local:3000/api/v1/lights/$LIGHT_ID/state

PUT …/segments sets individual LED segments on an addressable strip (only lights that advertise capabilities.segments; Govee today). Each entry carries a 0-indexed segment plus an optional rgb (packed 24-bit 0xRRGGBB) and/or optional brightness (0–100) — set either or both. Same status codes as …/state (502 if the light has no segment support). Write-only — segment state isn't reported back in LightState.

# Paint segments 0–2 red, 3–5 blue
curl -X PUT -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"segments":[{"segment":0,"rgb":16711680},{"segment":1,"rgb":16711680},
                   {"segment":2,"rgb":16711680},{"segment":3,"rgb":255},
                   {"segment":4,"rgb":255},{"segment":5,"rgb":255}]}' \
  http://bifrost.local:3000/api/v1/lights/$LIGHT_ID/segments

Rooms

A room is Bifrost's user-defined grouping (which may be linked to a provider's native group). light_ids are the effective members: lights in the linked provider group plus any directly assigned.

Method Path Description
GET /api/v1/rooms All enabled rooms: [{ id, name, light_ids, media_device_ids, power_device_ids }]media_device_ids / power_device_ids are the room's audio/power members; control each via the audio/power endpoints
PUT /api/v1/rooms/{id}/state Apply a LightState to every member

Room writes respond 200 with { "applied": N, "failed": M } — per-light results, since a room can span providers and some members may be offline. 404 if the room has no members.

Scenes

Method Path Description
GET /api/v1/scenes All scenes (home + room-scoped)
POST /api/v1/scenes Capture a snapshot: { name, room_id? }201 { id, lights, power }. Omit room_id for a whole-home scene; pass a room id to scope it to that room's members. 404 if the room is unknown
POST /api/v1/scenes/{id}/activate Re-apply the scene → 200 { applied, failed } (404 if unknown/empty)
DELETE /api/v1/scenes/{id} Delete → 204

POST /scenes snapshots current live state — it has no color/brightness inputs of its own; the body is only { name, room_id? }. A blank name422 ("scene name is required"); an unknown room_id404. DELETE is idempotent (always 204, even for an unknown id).

Audio devices

Receivers and networked speakers (Onkyo via eISCP, Sonos via local UPnP).

{
  "id": "c41a…",
  "provider_id": "9b2e…",
  "name": "Onkyo receiver (192.168.1.40)",
  "kind": "receiver",              // receiver | speaker | tv | zone
  "capabilities": { "sources": true, "transport": true, "now_playing": true },
  "state": {
    "power": true,
    "volume": 35,                  // 0–100
    "mute": false,
    "source": "net",               // current input/app
    "source_list": ["net","tv","Hulu"], // selectable inputs / TV apps (omitted if none); switch by sending one as `source`
    "now_playing": {               // when available
      "title": "Karma Police",
      "artist": "Radiohead",
      "album": "OK Computer",
      "play_state": "playing"      // playing | paused | stopped
    },
    "reachable": true
  }
}

A device's capabilities may also include "favorites": true (Sonos) — the device exposes saved favorites you can start playing (see below) — and "grouping": true (Sonos speakers) — the speaker can be joined into/out of a provider-native synced playback group (see below).

Method Path Description
GET /api/v1/media/devices All media devices (cached state)
GET /api/v1/media/devices/{id} One device — live read, refreshes the cache
PUT /api/v1/media/devices/{id}/state Send a command (body below)
POST /api/v1/media/devices/{id}/cast Cast media to a TV / media player (body below)
POST /api/v1/media/play-on Natural-language TV control by name — play a title / open an app (body below)
GET /api/v1/media/devices/{id}/favorites List saved favorites (live read)
POST /api/v1/media/devices/{id}/favorites/play Start a favorite (body below)
POST /api/v1/media/devices/{id}/group Join this speaker into a group (body below)
POST /api/v1/media/devices/{id}/ungroup Remove this speaker from its group
PUT /api/v1/media/devices/{id}/receiver Bind this source to a receiver (body below)
PUT /api/v1/media/devices/{id}/companion Merge this entity into a primary as its companion (body below)

PUT …/state takes a sparse command — only the fields present are applied:

{
  "power": true,
  "volume": 40,
  "mute": false,
  "source": "spotify",
  "transport": "play"    // play | pause | stop | next | previous | toggle
}

Source names: receiver inputs (net, tv, bd, cbl, bluetooth, …), raw Onkyo SLI hex ("2B"), or a streaming service (spotify, tunein, deezer, tidal, airplay, internet-radio) — service names switch the receiver to NET and select the service in one call. On Sonos, source switches to a physical input — Line-In (amps/ports/Fives) or TV (soundbars) — when the player has one (advertised in source_list); power maps to play/pause.

Responses: 204 success, 404 unknown device, 422 invalid command (e.g. unknown source — message in body), 502 device unreachable.

A bound source's read (GET …/{id}) reports the receiver's volume/mute, and its receiver_id / receiver_source fields name the binding.

A media device is returned as an effective device — composite links are resolved server-side. A companion's state and capabilities are merged into its primary; a TV (kind: "tv") whose media_player shares hardware with a paired, enabled remote reports that remote's id as remote_id (else null), so a client can render the unified TV control (keypad + app launch) straight from the device without a separate remote lookup.

state.reachable / state.power are composite-resolved, not just the media_player's. The media_player is authoritative while reachable; when it is unreachable (a standby TV reports unavailable), Bifrost falls back to the device's members — the paired remote, companions — so the device reads reachable: true / power from whichever member is up, instead of "offline." With no member reachable it stays unreachable (a cold box) — a client can still issue {"power": true}, which wakes it over Wake-on-LAN. (A future native smart-TV provider joins this resolution as just another member.)

Power fans across the composite. A {"power": true} command drives the media_player and wakes the paired remote (a Wake-on-LAN nudge + the provider's turn_on) — the reliable way to bring a TV out of standby, where its media_player often reports unavailable; the command succeeds if either path works. A bound source's power-on also wakes its receiver and selects receiver_source. Power-off is left to the media_player (the remote/box is the same device, and a shared receiver may still serve others).

Cast media

Play media on a TV / media player. content_id + content_type are passed through to the device's provider (Home Assistant maps them to media_player.play_media; content_type is the provider-native kind — url, music, app, channel, playlist, …). To launch an app, use the remote's app-launch (/remote/devices/{id}/command); to switch to a known input/app, set source via PUT …/state.

// POST …/{id}/cast
{ "content_id": "https://example.com/stream.m3u8", "content_type": "url" }

Responses: 204 success, 404 unknown device, 422 the device doesn't support casting, 502 device unreachable.

Play on a TV by name

Natural-language TV control: name a TV (or its remote) and a phrase, and the shared content resolver does the right thing. "open <app>" launches that app from the device's catalog; "play <title>" searches the TV's libraries and plays the best match, then falls back to opening the TV's last-used app as the best guess for where the title lives. device matches a remote or media device by id or (case-insensitive, substring) name.

// POST /api/v1/media/play-on
{ "device": "bedroom TV", "query": "play Bob's Burgers" }

Responses: 200 { "ok": true, "said": "Playing Bob's Burgers." } when an action was taken (ok: false with a reason when nothing matched), 404 when no TV or remote matches the name, 502 when the device couldn't be reached.

Bind a source to a receiver

Real AV: a TV / streamer / console feeds audio through an AV receiver, which owns the volume. Bind the source to its receiver and PUT …/state to the source then routes volume/mute to the receiver, while power/source/transport stay on the source. Powering the source on also wakes the receiver and (if receiver_source is set) switches it to that input. Many sources may share one receiver. Stored on the source.

// PUT …/{id}/receiver
{
  "receiver_id": "<media device id>",  // null to unbind
  "receiver_source": "Game"             // optional: receiver input to select on power-on
}

Responses: 204 success, 404 unknown source device, 422 invalid binding (self-binding, unknown receiver, or a receiver that is itself bound).

Merge a duplicate device (composite)

One physical device can surface as several entities with complementary capabilities (e.g. a smart TV's two media_player views — one carries now-playing, the other the apps). Merge the secondary into a primary as its companion: the companion is hidden from control, but its state and controls are routed/overlaid onto the primary — nothing is lost (unlike a hidden duplicate). volume/mute route to whichever backing is receiver-bound, transport to the one reporting playback, source to the one with inputs, and power to the primary; the merged read fills now-playing / sources / the receiver binding from the companion.

// PUT …/{id}/companion
{
  "primary_id": "<media device id>"   // null to unmerge
}

Responses: 204 success, 404 unknown device, 422 invalid (self-merge, an unknown/companion/shadowed primary, or a device that is itself a primary).

Favorites

Favorites are the presets the user already saved on the provider (e.g. Sonos Favorites — playlists, stations). No accounts or search: you list them and start one by reference.

// GET …/favorites
[
  { "id": "FV:2/12", "title": "Jazz", "subtitle": "Spotify" },
  { "id": "FV:2/3", "title": "BBC Radio 6", "subtitle": "TuneIn" }
]

POST …/favorites/play takes the id in the body (provider ids contain slashes):

{ "favorite_id": "FV:2/12" }

Responses: list → 200 with the array (empty for providers without favorites, such as Onkyo); play → 204 success, 404 unknown device, 422 unknown favorite, 502 device unreachable.

Grouping (provider-native)

Speakers with "grouping": true (Sonos) can be joined into a synced playback group that plays in sync, controlled through a coordinator — the provider's own grouping, independent of Bifrost Rooms. POST …/{id}/group joins the speaker {id} into the group coordinated by another speaker:

{ "coordinator_id": "<another media device id>" }

POST …/{id}/ungroup removes the speaker from any group (returns it to standalone playback; idempotent). Both speakers must belong to the same provider. After a change, the household topology shifts — re-run discovery (POST /api/providers/{id}/discover) to surface the synced-group zone device.

Responses: 204 success, 404 unknown device, 422 invalid (different providers, or grouping a speaker with itself), 502 device unreachable.

Power devices

Strictly on/off endpoints — switches, smart plugs, fans, boolean helpers — surfaced by integration providers (Home Assistant today). A power device has no capability set; its whole state is on. kind is presentational (drives the UI glyph) and is one of switch | outlet | fan | toggle | generic.

{
  "id": "5d2f…",
  "device_id": "switch.porch",  // provider-native id (e.g. HA entity_id)
  "name": "Porch",
  "kind": "switch",
  "state": { "on": true, "reachable": true }
}
Method Path Description
GET /api/v1/power/devices All power devices (cached state)
GET /api/v1/power/devices/{id} One device — live read, refreshes the cache
PUT /api/v1/power/devices/{id}/state Set power: body { "on": true|false }

PUT …/state responds 204 on success, 404 unknown device, 502 if the device could not be reached.

Remotes

A virtual smart-remote for a TV / streamer (Android TV Remote via Home Assistant today). State is on plus the foreground app (current_app, a package id).

{
  "id": "9a1b…",
  "device_id": "remote.bedroom_tv",  // provider-native id (e.g. HA entity_id)
  "name": "Bedroom TV",
  "state": { "on": true, "current_app": "com.netflix.ninja", "reachable": true }
}

GET /remote/devices/{id} returns just the live state object (the RemoteState), not the full device record.

Method Path Description
GET /api/v1/remote/devices All remotes (cached state)
GET /api/v1/remote/devices/{id} One remote — live read, refreshes the cache
POST /api/v1/remote/devices/{id}/command Send a command (see below)

The command body is a tagged union — exactly one variant:

{ "key": { "key": "select", "hold_secs": 0.0 } }   // canonical key press
{ "text": { "text": "hello" } }                      // type literal text
{ "launch_app": { "activity": "com.netflix.ninja" } } // package id OR deep-link URL
{ "power": { "on": true } }                          // power on/off

Canonical keys: up, down, left, right, select, back, home, menu, volume_up, volume_down, mute, play_pause, next, previous, power. POST …/command responds 204 on success, 404 unknown remote, 502 if it could not be reached.

Sensors (read-only)

Motion, occupancy, contact, illuminance, temperature, and humidity inputs, surfaced by Philips Hue (motion accessories) and Home Assistant. Sensors are read-only — there is no set-state route. kind is one of motion | occupancy | contact | illuminance | temperature | humidity | generic; state.reading is a self-describing tagged value — {"bool": true} for motion/occupancy/contact, {"number": 21.5} for illuminance/temperature/humidity — and unit (°C, lx, %) is present when the provider reports one. Presence kinds (motion/occupancy) also feed each Room's occupancy aggregation server-side.

{
  "id": "c4e8…",
  "device_id": "binary_sensor.hallway_motion",
  "name": "Hallway motion",
  "kind": "motion",
  "unit": null,
  "state": { "reading": { "bool": true }, "reachable": true }
}
Method Path Description
GET /api/v1/sensors/devices All sensors (cached state)
GET /api/v1/sensors/devices/{id} One sensor — live read, refreshes the cache

Live freshness normally arrives on the provider's push channel (Hue SSE / Home Assistant WebSocket), so the cached list is current in practice; the single-device read forces a poll for providers without push.

Status codes

Code Meaning
200 / 201 / 204 Success (body / created / no body)
401 Missing or revoked API key
404 Unknown device, room, or scene
422 Validation failure (message in body)
502 Provider unreachable (device offline, bridge down)

Key management (UI/session only)

Keys are managed with a browser session, not with another key — a leaked key cannot mint more keys: GET/POST /api/api-keys, DELETE /api/api-keys/{id}.

Device enrollment (QR pairing)

Headless devices (the wall-tablet voice satellite) get a key without anyone typing one. An authenticated dashboard session mints a short-lived, single-use token; the device scans a QR carrying it and redeems it for a normal bfr_ key (which then shows up in Settings → API keys and is revocable like any other).

Method Path Auth Purpose
POST /api/enrollment session Mint a pairing token → { token, expires_at, expires_in_secs } (TTL 5 min)
POST /api/enrollment/redeem token (no key/session) { token, device_name? }201 { key, prefix, name }

The redeem route is intentionally unauthenticated — the device has no credential yet — but is gated on a valid, unexpired, unused token, which only an authed session can create. Redemption is atomic and single-use; a replayed token gets 401.

The dashboard renders the QR as JSON { "v": 1, "base_url": "<origin>", "token": "<token>" }. A client scans it, then POSTs { token, device_name } to base_url + /api/enrollment/redeem and stores the returned key for all subsequent /api/v1 + /api/voice calls.

Kiosk control (/api/kiosks)

Manage the wall-tablet companion apps. A kiosk is identified by the bfr_ key it was paired with (via enrollment); it checks in on a heartbeat and the server hands back a queued command. Management endpoints are session-only (driven from a phone/desktop), so they aren't reachable with a kiosk key — and the companion app sets a BifrostKiosk/<ver> User-Agent suffix so the frontend hides this view on the kiosk itself.

Method Path Auth Purpose
POST /api/kiosks/checkin kiosk key Heartbeat: { name?, app_version?, screen_on?, battery/power telemetry… }{ command, room, default_board_id } (queued command, consumed; room is the assigned Room's name — the app's voice context)
GET /api/kiosks/self kiosk cookie The kiosk's own record ({ id, name, default_board_id }) — how a kiosk-served Boards page resolves its auto-launch board
GET /api/kiosks/stream kiosk key Live SSE command channel — controller commands are pushed here instantly; the queued copy is the offline fallback
GET /api/kiosks session List kiosks: check-in status, assignments, schedule/presence config, battery telemetry
POST /api/kiosks/{id}/command session Queue { command } — one of sleep, wake, lock, update
PUT /api/kiosks/{id}/room session Assign the kiosk to a Room ({ room_id }, null clears) — its voice context and presence source
PUT /api/kiosks/{id}/board session Set the board to auto-launch full-screen ({ board_id }, null clears)
PUT /api/kiosks/{id}/schedule session Scheduled quiet hours: { enabled, sleep_at, wake_at } (server-local "HH:MM"; both required and distinct when enabled)
PUT /api/kiosks/{id}/presence session Presence-driven blanking: { enabled, timeout_secs? } (no-motion grace, clamped 30–3600 s)
POST /api/kiosks/{id}/deauth session Revoke the kiosk's key (it must re-enroll)
DELETE /api/kiosks/{id} session Forget a kiosk record

The OTA update relay lives under /api/kiosks/update (session triggers/inspects the hub-side APK cache; key-auth manifest/apk routes feed the kiosk over the LAN).

Command semantics (the app performs these on push or check-in): - sleep / wake — turn the display off / on. - lock — force sign-out of the Bifrost WebView session (re-enter password). - update — pull the hub-cached APK and self-install. - de-auth is not a queued command — it revokes the key immediately, so the app's next call gets 401 and it re-enrolls via a fresh QR scan.

Display power saving is server-driven: a background scheduler issues the same sleep/wake commands from each kiosk's quiet-hours schedule and, by day, from its assigned Room's occupancy (presence sensors) — a manual wake holds until the next boundary. Configure both per kiosk in Settings → Clients.

Companion-app contract: check in every ~30–60s with the paired key; act on a returned command (and clear nothing — the server consumes it); treat a 401 from any authed call as "de-authed" → drop to QR enrollment. Set the WebView User-Agent to include BifrostKiosk/<version>.

Voice control (/api/voice)

Natural-language command endpoints, gated by the same Bearer API keys as /api/v1 (a browser session also works, for the web conversation modal). This is the contract the headless wall-tablet voice satellite uses — it has no login cookie, so it sends a minted bfr_ key like any other public-API client.

Method Path Purpose
POST /api/voice/command Run a text command (fallback chain below)
POST /api/voice/listen Upload audio; server transcribes (configured STT) then runs it
POST /api/voice/speak Synthesize spoken audio for text via the configured TTS model; returns the audio bytes
GET /api/voice/vocabulary { words: [...] } — the command-grammar keywords plus every enabled room/device/scene name (tokenized). A device with on-device STT (the wall tablet) biases its recognizer to this list so in-domain words aren't misheard.

A clause is resolved native-first: the deterministic grammar parses what it can; a clause it can't parse falls to the configured chat LLM (OpenAI-compatible tool-calling — it maps the phrasing to the same internal command and dispatches via the shared service layer); failing that, to Home Assistant Assist. Each fallback is optional — with neither configured, an unparsed clause returns "I didn't understand". Configure the chat model under PUT /api/ai-endpoints/chat.

/api/voice/command — JSON in, JSON out:

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"text":"bifrost, turn off the office", "context":{"room":"office"}}' \
  http://bifrost.local:3000/api/voice/command
// request
{ "text": "...", "context": { "room": "<id-or-name>" } }  // context optional; room disambiguates bare references
// response
{ "ok": true, "said": "Turned off the office.", "clauses": [ { "heard": "...", "ok": true, "said": "..." } ] }

/api/voice/listenmultipart/form-data with an audio file field (and an optional room text field). Returns the same shape plus the recognized transcript. Returns 503 when no transcription model is configured (so text control over /command never depends on STT being up):

curl -X POST -H "Authorization: Bearer $KEY" \
  -F file=@utterance.wav -F room=office \
  http://bifrost.local:3000/api/voice/listen

/api/voice/speak — JSON in ({ "text": "...", "voice": "alloy", "format": "mp3" }; voice/format optional), audio bytes out (the Content-Type reflects the synthesized format). Spoken talk-back for a reply — pass it the said line from a command. Returns 503 when no TTS model is configured:

curl -X POST -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"text":"Turned off the office."}' \
  http://bifrost.local:3000/api/voice/speak --output reply.mp3

MCP endpoint (/mcp)

Bifrost embeds a Model Context Protocol server so an AI assistant can control the home in natural language. It is served at POST /mcp as a Streamable HTTP endpoint (stateless, JSON responses) and gated by the same Bearer API keys as /api/v1:

Authorization: Bearer bfr_<your-key>
Content-Type: application/json
Accept: application/json, text/event-stream

A missing or invalid key returns 401 before any MCP processing. The MCP tools call the same shared service layer as the routes above, so behaviour can't drift from the REST surface. Tools resolve lights, rooms, scenes, and media devices by id or case-insensitive name/substring. The tool catalogue and mapping live in MCP server. stdio-only clients can bridge to this endpoint with the standard mcp-remote shim — there is no separate stdio server.

Versioning

The /api/v1 surface is additive-stable: fields may be added to responses, but existing fields and routes will not change meaning within v1. Breaking changes get a new prefix.