A step-by-step guide to building a Roon REST API extension that lets an AI assistant control your music — and a deep dive into what the Extension API can and can’t do.
Nice write up
I’ve tested it with curl. Playing tracks does work, but when I try to play an album, I get the following error. Tried different album titles without success.
./test-play.sh
{“error”:“Action "Play Now" not found”,“available”:[“The Rainbow Children”]}
cat test-play.sh
#! /bin/bash
curl -X POST http://roonext2.home:3001/api/find-and-play
-H “Content-Type: application/json”
-d ‘{
“zone_id”: “1601dcef8115529daf4cd6807753971fae3e”,
“query”: “The Rainbow Children”,
“type”: “Albums”,
“action”: “Play Now”
}’
Good catch! I’ve updated it to play albums /api/play-album just put the updated extension.js in the container folder and restart the container.
Great, play-album endpoint works
Time to integrate it into OpenClaw ![]()
Skill in OpenClaw is up and running. I’ve already tried different solutions (Roon Command Line, unified-hifi-control MCP server) in OpenClaw. Your solution is by far the most reliable and fastest ![]()
Does this only work if you’re running cowork from a Mac? I tried this on a Windows PC and Claude can’t seem to figure out a way to adapt the skill to run on Windows.
@David_Niegowski I had a similar problem, when running the skill in OpenClaw on Linux. The Applescript commands in the skill only work on Mac. So i had to modify the skill. I copied & pasted the commands into Claude chat and ask to convert it to bash scripts.
Since you are using Windows, you have to convert the Applescript parts into Powershell commands..
I have added to the skill some options for windows, hopefully one of them will work. There is a roon_control.py file in the repo that you might allow it make the calls outside the sandbox on windows. I’m afraid I can’t test it, as I don’t have a windows machine. Hope it works. Let me know.
FYI: roon_control.py does not work with Python 3.13.5 on Debian Trixie. When trying to search i get an error. Turned out to be a Python scoping issue. Required only a small fix.
> python3 roon_control.py search “Lawrence”
Traceback (most recent call last):
File “/root/roon-controller/roon_control.py”, line 228, in
CMDSargs.cmd
~~~~~~~~~~~~~~^^^^^^
File “/root/roon-controller/roon_control.py”, line 92, in cmd_search
results = _get(‘/search’, params)
File “/root/roon-controller/roon_control.py”, line 39, in _get
qs = ‘&’.join(f’{k}={urllib.parse.quote(str(v))}’ for k, v in params.items())
File “/root/roon-controller/roon_control.py”, line 39, in
qs = ‘&’.join(f’{k}={urllib.parse.quote(str(v))}’ for k, v in params.items())
^^^^^^
NameError: cannot access free variable ‘urllib’ where it is not associated with a value in enclosing scope. Did you forget to import ‘urllib’?
Fix
--- roon_control.py.1 2026-03-07 10:51:03.464247931 +0100
+++ roon_control.py 2026-03-07 10:54:12.431993245 +0100
@@ -24,6 +24,7 @@
import argparse
import urllib.request
import urllib.error
+import urllib.parse
# ── Configuration ─────────────────────────────────────────────────────────────
# Change QNAP_IP to your QNAP's actual IP address (e.g. 192.168.1.50)
@@ -38,7 +39,6 @@
if params:
qs = '&'.join(f'{k}={urllib.parse.quote(str(v))}' for k, v in params.items())
url = f'{url}?{qs}'
- import urllib.parse
req = urllib.request.Request(url)
try:
with urllib.request.urlopen(req, timeout=10) as r:
@@ -53,7 +53,6 @@
def _post(path, payload):
- import urllib.parse
data = json.dumps(payload).encode()
req = urllib.request.Request(
BASE_URL + path,
Thanks. I’ll update it.![]()
Updated now. Should be OK. I also found Claude was picking artists other than the original artist if they were more popular which is fixed now in the extension and the skill.
I tried the new version. Issues with queue list and clear:
> curl http://roonext2.home:3001/api/queue/1601dcef8115529daf4cd6807753971fae3e
Mär 08 16:47:40 RoonExt2 node[2043]: TypeError: _transport.get_queue is not a function
Mär 08 16:47:40 RoonExt2 node[2043]: at /root/roon-controller/extension.js:570:14
Mär 08 16:47:40 RoonExt2 node[2043]: at Layer.handle [as handle_request] (/root/roon-controller/node_modules/express/lib/router/layer.js:95:5)
Mär 08 16:47:40 RoonExt2 node[2043]: at next (/root/roon-controller/node_modules/express/lib/router/route.js:149:13)
Mär 08 16:47:40 RoonExt2 node[2043]: at Route.dispatch (/root/roon-controller/node_modules/express/lib/router/route.js:119:3)
Mär 08 16:47:40 RoonExt2 node[2043]: at Layer.handle [as handle_request] (/root/roon-controller/node_modules/express/lib/router/layer.js:95:5)
Mär 08 16:47:40 RoonExt2 node[2043]: at /root/roon-controller/node_modules/express/lib/router/index.js:284:15
Mär 08 16:47:40 RoonExt2 node[2043]: at param (/root/roon-controller/node_modules/express/lib/router/index.js:365:14)
Mär 08 16:47:40 RoonExt2 node[2043]: at param (/root/roon-controller/node_modules/express/lib/router/index.js:376:14)
Mär 08 16:47:40 RoonExt2 node[2043]: at Function.process_params (/root/roon-controller/node_modules/express/lib/router/index.js:421:3)
Mär 08 16:47:40 RoonExt2 node[2043]: at next (/root/roon-controller/node_modules/express/lib/router/index.js:280:10)
> curl -s -X POST http://roonext2.home:3001/api/queue/clear -H 'Content-Type: application/json' -d '{"zone_id": "1601dcef8115529daf4cd6807753971fae3e"}'
{"error":"Queue not found in root browse","log":[{"step":"root","items":[{"title":"Library","hint":"list"},{"title":"Playlists","hint":"list"},{"title":"My Live Radio","hint":"list"},{"title":"Genres","hint":"list"},{"title":"TIDAL","hint":"list"},{"title":"Settings","hint":"list"}]}]}
both confirmed and fixed.
Queue list (GET /api/queue) — this was a straightforward mistake on my part. I’d called _transport.get_queue() which doesn’t exist in RoonApiTransport. The correct method is subscribe_queue(), which uses the same subscription callback pattern as subscribe_zones. Fixed in v0.6 — it now returns the queue items correctly.
Queue clear — this one isn’t fixable, unfortunately. I’ve confirmed exhaustively that the Roon Extension API simply doesn’t expose queue clearing to third-party extensions. The hierarchy:'browse' root browse never includes a Queue item regardless of zone context, and hierarchy:'queue' returns InvalidHierarchy. The old endpoint was dead code that would always fail.
The only way to replace the queue via the API is Play Now on any track or album — Roon atomically clears the existing queue and starts the new one. The endpoint now returns a proper 501 Not Implemented with a workaround note rather than a confusing 404.
Both fixes are in the latest extension.js on GitHub. Just copy it in to the folder and restart the container — no rebuild needed.
Nice tinkering project, congrats ![]()
For those of you who like to get a Plug&Play solution please check out rooAIDJ which is an Artificial Intelligence Disk Jockey for Roon.
It is Part of my rooExtend suite.
Also a Plug&Play Hardware, the rooExtend-Box is available for those that don’t want to tinker.
Best DrCWO
Ok, I have Claude Code running on my Synology NAS now and am able to use the Claude app on my phone to prompt the skill. Pretty cool to play around with. If you give it a more complex prompt it does take a while but eventually it starts playing.
It would be great if we could somehow get this working from the search bar in Roon or with Siri or some other voice assistant!
I‘m using my Alexa devices to control it by voice ![]()
Alexa → Home Assistant → OpenClaw → Roon REST API → Roon Server
I almost have something similar working. In my case its:
Siri > Zapier > Slack > Claude > Roon REST API > Roon Server
I have updated quite a bit on the repo and seems to work pretty well at this point. It can transfer between zones and put my Hegel into standby now. I’ve probably stopped tinkering with it at this point. Enjoy.