Music tool
Full voice/chat control of Music Assistant (MA) through
Home Assistant. One inner tool, music, with an action enum. It reuses the same
ha_url/ha_token the other HA tools use and calls HA's REST API:
music_assistant.*—play_media,search,get_library,get_queue,transfer_queuemedia_player.*— transport, volume, shuffle, join/unjoinbutton.press— favorites
MA's own docs note there is no native HA voice support for initiating playback (HA intents only cover next/prev/pause/volume) and point to an LLM recognizer as the way forward. Muninn is that LLM, so it drives MA directly and sidesteps HA's intent layer.
Source: src/tools/music.rs.
Two backends
There are two independent ways Muninn can reach MA; they coexist:
- The native
musictool (this doc) — HA REST → MA. The primary, role-gated (admin/adult/child), human-shaped surface: play modes, room targeting, DJ, taste split. This is what a "play some music on the bar" request hits. It needs onlyha_url/ha_token(+music_assistant_config_entry_idfor search/genre/favorites). - An optional MA MCP server — a
[[tools.mcp_servers]]entry (name = "music",kind = "http",url = "http://<ma-host>:8095/mcp/v1", Bearer token) pointing at Music Assistant's own FastMCP endpoint. This exposes MA's ~70 raw tools (playback_*,library_*,players_*,volume_*,config_*,debug_*, …) directly to the sub-agent. It's admin-gated (all MCP tools are) and unconfigured by default. Useful for low-level / debugging access; the native tool is preferred for everyday control. Requires MA's MCP Server plugin to be enabled on the server and a long-lived MA token. ⚠️ Itsurl/token are instance-specific — repoint both if the MA server moves.
What you can say
Talk to Muninn naturally — chat or voice. The sub-agent maps the request onto a music call.
Playing
| Say | Result |
|---|---|
| "play Royals" / "play music by Maluma" / "play the album X" / "play my workout playlist" | named content, searched across the whole library |
| "play some music" | favorites first, then random fill, shuffled, varies per room, minus disliked tracks |
| "surprise me" / "play stuff I don't usually play" / "some deep cuts" | random library excluding favorites (discover mode) |
| "play music like this" / "more like Royals" / "Daft Punk radio" | endless similar-tracks station (radio mode) |
| "play some jazz" / "90s music" / "something chill" / "workout music" | a matching playlist (genre mode) |
| "play it on the bar" / "in the kitchen" / "in here" | targets that room (voice knows the originating room) |
| "add X to the queue" / "play X next" | enqueue instead of replace |
Transport & volume
| Say | Result |
|---|---|
| "pause / resume / stop / next / skip / previous" | acts on one player |
| "pause everything" / "stop the music everywhere" | every playing speaker at once |
| "turn it up / down" / "set volume to 40%" / "mute / unmute" | volume |
| "shuffle on / off" | shuffle toggle |
| "what's playing?" / "what's the volume?" | status (track + volume + mute) |
Taste
| Say | Result |
|---|---|
| "I like this" / "add to favorites" | favorites the current song in MA |
| "I don't like this one" | skips it now and remembers to avoid it (Muninn memory) |
Multi-room
| Say | Result |
|---|---|
| "move it to the kitchen" | transfer the queue to another speaker |
| "join the bar to the gym" | synced playback — bar plays the gym's audio in sync |
| "ungroup the bar" | remove a speaker from its group |
Play modes
The play action picks a strategy from the optional mode parameter
(favorites | discover | radio | genre), or infers it from phrasing when mode is absent.
| Mode | Trigger | Mechanism |
|---|---|---|
| favorites (default) | bare "play", or an all-generic query ("some music", "something", "tunes") | get_library favorite=true (order_by=random) + random fill if <25; minus dislikes; shuffle_set(true) |
| discover | mode=discover, or "surprise me / something I don't usually play / deep cuts / haven't heard / explore" |
random get_library pool minus the favorites URI set (and dislikes); falls back to plain random if the library is all-favorites |
| radio | mode=radio, or "like this / more like X / similar to / sounds like / X radio / X station" |
play_media radio_mode=true, seeded from query (fluff stripped by radio_seed) or, if none, the current track |
| genre | mode=genre, or a decade ("90s", "2000s"), or the word "genre", or a query made only of known genre/mood/activity words ("jazz", "classic rock", "something chill" — GENRE_VOCAB + looks_like_genre) |
search media_type=[playlist] → play the top playlist; fallback if no playlist matches: a radio station seeded on the term |
| (specific) | any other named query | play_media media_id=<query> — MA resolves the song/artist/album/playlist. The confirmation reads back the track that actually started (confirm_new_track, ≤ ~0.7 s) and names that, flagging it when it shares no word with the query; if it can't be read back in-turn (cold device), it says so instead of echoing the query as if matched |
Why genre uses playlists: this library is YouTube-Music-backed with no genre or year metadata
on tracks (get_library with search="jazz" returns nothing; there is no genre library). Genres
live in curated playlists ("90s Hits Playlist", "Best of Classic Rock", "Phonk Workout"), so a
playlist search is the reliable way in. Open-vocabulary genres can't be rule-matched, so unusual
genres still rely on the LLM setting mode=genre; decades, the literal word "genre", and a closed
vocabulary of common bare genre/mood words (GENRE_VOCAB) are auto-detected. The vocabulary rule
exists because the LLM routinely omits mode: until 2026-10-17 a bare "jazz" fell to the specific
play and MA resolved it to Will Smith's "Gettin' Jiggy Wit It" — with a confirmation that echoed
"jazz" back, so nobody could tell. The vocabulary rule yields to an explicit other mode.
DJ mode (radio host)
Optional — [tools.dj] enabled, off by default; needs MA + a configured TtsRouter
([tools.tts]). Source: src/dj.rs.
Flips a per-player radio-host mode on via the dj action (aliases radio_host /
radio host; the LLM's common {"action":"play","mode":"dj"} is normalized to canonical
action:"dj", and bot.rs also has a deterministic toggle).
| Say | Result |
|---|---|
| "be a DJ for the basement" / "DJ mode on" | turns radio-host mode on for that player |
| "stop being a DJ" / "DJ off" | turns it off for that player |
How it behaves:
- A background worker polls each active player (
5s into every new track** (poll_secs, default 3s) and **announce_after_secs) speaks a short on-air intro over the music — ducked via theTtsRouterannounce path, so the song dips under the voice and recovers (no mute/jump). - The announcer persona is chosen by the song's detected language; the patter vibe rotates
each song (round-robin) so it never repeats. Personas + vibes live in an editable markdown file
(
[tools.dj] md_file, defaultdj.md), re-read per announcement — retune without a rebuild (missing/unparseable → baked-in defaults). - Optional web grounding (
web_grounding, default on) runs a quick SearXNG search so the patter cites real artist/song facts instead of hallucinating (time-budgeted → fact-free fallback; inert without[tools] searxng_url). - The active-player set is persisted (JSON) so DJ mode survives a restart; a forgotten session
auto-expires after
idle_timeout_secs(default 30 min) of non-playback.
Config [tools.dj]: enabled, md_file, poll_secs, announce_after_secs,
idle_timeout_secs, web_grounding.
Behavioural details
- Resume vs restart — a non-specific "play" on a paused player resumes where it left off instead of building a fresh queue. A named song always starts fresh.
- Per-room variety — favorites/discover queues are pulled
order_by=random, so each room (and each request) gets a different opener instead of always starting on the same track. - Grouped pause doesn't cascade — pausing/stopping one speaker that's in a sync group first
detaches it (
detach_if_groupedunjoins, then polls ~1.6 s until it has actually left) so only that speaker stops. Without this, MA pauses the whole synced session. - "radio"/"station" are whole-word matches — so "play Radiohead" or "Radio Ga Ga" still play the actual artist/song, not a station.
- Slow-device backgrounding — an idle cast/ESPHome target can take ~7 s to cold-start a stream
vs ~0.2 s for a warm MA player.
play_mediais raced against a 1.5 s deadline: if it finishes fast the real result is surfaced; if it's still connecting the POST is left running and Muninn answers optimistically so the turn doesn't hang. Trade-off: a genuine failure on a slow device won't be reported in-turn.
Taste split
Likes and dislikes live in two different systems by design:
- Likes → MA favorites. The current song's favorite button
(
button.<player>_favorite_current_song, matched by friendly-name) is pressed. Favorites are a persistent MA store and a play source (get_library favorite=true). - Dislikes → Muninn memory. MA has no dislike concept, so a dislike skips the track now and
records a personal-scoped preference memory (
category=preference). Future favorites/discover queues drop tracks whose title matches a dislike memory (best-effort title containment; a structured taste table is a follow-up).
Player resolution
player accepts a room word, a player name, a phone, or a raw media_player.* id. Resolution
order:
- Explicit entity id (
media_player.*) - A player named outright — its friendly name or entity id (±the
media_player.prefix), compared separator-insensitively. ⚠️ Before the room map on purpose: both the map and the area heuristic match by containment, so "Gym PC Assistant" (contains the room keygym) was silently redirected to the gym's default player — the treadmill phone one wall away, reported as success. - A phone — "my phone" / "mi teléfono" is the phone of whoever is speaking, resolved
through
[[users]] phone_player; "Matthew's phone" resolves by name. ⚠️ This cannot live in[tools.music_rooms]: that table is flat, so one"my phone"key would answer with one person's phone for the whole household. A NAMED miss falls through (so the legacyoz phoneroom key still works); "my phone" with nophone_playersays so instead of guessing. - Config room map —
[tools.music_rooms]curated per-room default (exact, then longest contained key), separator-insensitive - HA area — one
/api/templatecall usingintegration_entities('music_assistant')+area_name(p), matching the word to an area (longest wins), preferring a speaker over a TV/display - Device registry — for voice, the originating
ctx.room→ itsmedia_player - Fuzzy name match against friendly names / entity ids (
norm()strips spaces/hyphens/ underscores so "pixel 3xl" matches "pixel-3xl")
If nothing resolves, the reply lists the available players and room words so the LLM can pick in
one step. music_default_player is the final fallback; for "pause/stop everything" no single
target is resolved.
Is the target actually there? (ensure_reachable)
⚠️ HA answers music_assistant.play_media for a player that is not there with HTTP 200 and an
empty changed-states array (measured 2026-09-11), and play_media_nonblocking reads any 2xx as
success. So every acting action (everything but status, which should be able to report an off
player) first checks the resolved target is a live MA player.
A phone is the case that makes this bite: its player leaves Music Assistant entirely while the
speaker is off, and the Kai app's speaker switches itself off after 30 idle minutes. If the target
is a phone with a phone_switch, the tool turns that switch on and polls for up to
PHONE_WAKE_SECS (15 s) for MA to see it, then plays. Otherwise it says the phone is off — it
never claims to be playing at a silent speaker.
Configuration
In [tools] (set in muninn.toml.local, since deploy.sh ships only the binary):
[tools]
ha_url = "http://<ha-host>:8123"
ha_token = "<long-lived-access-token>"
music_assistant_config_entry_id = "<MA-integration-entry-id>" # enables search / library / genre — instance-specific, see note
music_default_player = "media_player.zeus_desk" # optional final fallback
# Curated per-room DEFAULT player. Checked before HA-area resolution, so these
# pin "the one I mean" for multi-player rooms. Rooms not listed resolve via HA areas.
[tools.music_rooms]
bar = "media_player.pixel_3xl"
kitchen = "media_player.nesthubmax46b3_2"
gym = "media_player.pixel_6a"
# ...
# Players that are only a SOURCE feeding an external amp: volume/up/down/mute —
# and the level reported by `status` — go to the amp instead of the player.
[tools.music_volume_entities]
"media_player.movies_room_2" = "media_player.tx_rz30"
A phone belongs to a PERSON, not to a room word — it moves with them, and "my phone" means a different phone for each speaker:
[[users]]
user_id = "oz"
display_name = "Oz"
phone_player = "media_player.oz_phone" # its Music Assistant player
phone_switch = "switch.kai_phone_speaker" # turns that phone's speaker on
phone_switch is optional but nearly always wanted: without it, a phone whose speaker is off can
only be reached after its owner turns the speaker on by hand.
Why music_volume_entities exists. A player like the movies room is a source, not an
amplifier: Sendspin on the Google TV Streamer → HDMI → Onkyo TX-RZ30 → speakers. That puts two
gain stages in series, and the MA player's own volume normally sits pinned at 100%, so "turn it
up" has nowhere to go — the amp, sitting at whatever level it powered on with, is the real
ceiling. Mapping the player to the amp here makes the amp the single volume authority. Music
Assistant cannot do this itself: for a Sendspin player, volume_control only offers
follow_protocol or none (no HA-entity option, unlike power_control).
Keys are matched case-insensitively against the resolved media_player.* entity; players not
listed keep controlling their own volume. Leave the source player at 100% so it stays at unity —
if something drops it, you are back to two stages in series.
Finding the config entry id. Easiest is HA's template engine — render
{{ config_entry_id('media_player.<any MA player>') }} (e.g. the bar player) via
Developer Tools → Template, or over REST:
curl -s -X POST -H "Authorization: Bearer $HA_TOKEN" -H "Content-Type: application/json" \
-d '{"template":"{{ config_entry_id(\"media_player.pixel_3xl\") }}"}' \
"$HA_URL/api/template"
(Also available via the HA WebSocket config_entries/get or the MA integration page.)
⚠️ This id changes if you remove and re-add the MA integration — e.g. when repointing HA from an MA add-on to a standalone MA server on another host. After any such move, re-render the id and update this config, or
search/genre/favoritessilently break (playback to a player still works, because that goes through themedia_player.*entities, which survive the re-add). The room map ([tools.music_rooms]) entity ids survive too — HA dedupes by unique_id.
Permissions
music is allowed for admin / adult / child (playback is low-risk); denied for guest /
unknown. It is not in HA_RESTRICTED_TOOLS — a pure role gate, so kids don't need to be in
ha_allowed_users. See the role matrix in CLAUDE.
Follow-ups (not built)
- Structured taste table (hard like/dislike weighting) vs prose-memory dislikes
- Quiet-hours / per-room volume cap
- Robust favorite-button mapping via entity-registry
device_id(vs friendly-name match) - Person/time-aware default seed ("play some music" differs by who/when)
- Presence-driven auto-follow (transfer the queue as you move between rooms)