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" |
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 |
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 genre
detection is mostly LLM-driven via mode=genre; only decades and the literal word "genre" are
auto-detected.
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, or a raw media_player.* id. Resolution order:
- Explicit entity id (
media_player.*) - 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.
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"
# ...
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)