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:
Missing or unknown keys get 401 Unauthorized on every endpoint.
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 name →
422 ("scene name is required"); an unknown room_id → 404. 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.
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.
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.
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):
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:
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/listen — multipart/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.