Soundsible Agent Integration Guide
This guide is for OpenClaw, Hermes agents, local assistants, and agent skills that need to control a running Soundsible Station Engine.
Soundsible is not a cloud music API. It is a self-hosted Station Engine, usually at:
http://localhost:5005
On LAN or Tailscale, replace localhost with the Station host IP or hostname.
In desktop-engine mode, the base URL is not assumed to be :5005. Read the readiness JSON emitted by python3 run.py --desktop-engine / soundsible_engine.py, or query /api/health from the known loopback URL and use its reported base_url.
Agent Rules
- Use the Agent API for playback control:
/api/agent/*. - Do not invent device IDs. Read
/api/devicesand target a realdevice_idor exactdevice_name. - If a response includes
warning: "Device appears offline (no active socket)", the command was emitted but no browser player is currently connected to that device room. - Deezer is metadata only. Soundsible never plays Deezer audio.
- For playable search results outside the library, use YouTube / YouTube Music search through the ODST / yt-dlp path.
- The playback queue is not the same as the download queue.
- Prefer
/api/agent/playfor “play this now”. Prefer/api/playback/queuefor “add this to the player queue”.
Authentication
Agent endpoints require a Soundsible agent token.
Agent tokens are now stored in scoped auth_tokens records. Legacy agent_tokens compatibility still exists internally, but new integrations should treat the scoped token model as the source of truth.
Create a token:
curl -X POST http://localhost:5005/api/agent/token \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <SOUNDSIBLE_ADMIN_TOKEN>' \
-d '{"name":"openclaw"}'
If SOUNDSIBLE_ADMIN_TOKEN is not configured, admin routes are allowed only from trusted LAN/Tailscale networks.
The response includes the raw token once:
{
"token": "raw-token-value",
"token_id": "uuid",
"name": "openclaw",
"created_at": "2026-05-05 18:00:00"
}
Store the raw token in the agent secret store. Soundsible stores only a hash.
You can optionally request a narrower scope set when creating a token:
curl -X POST http://localhost:5005/api/agent/token \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <SOUNDSIBLE_ADMIN_TOKEN>' \
-d '{"name":"openclaw","scopes":["library:read","playback:control"]}'
Use one of these headers on protected agent routes:
Authorization: Bearer <agent-token>
X-Soundsible-Agent-Token: <agent-token>
Verify a token:
curl http://localhost:5005/api/agent/verify \
-H 'Authorization: Bearer <agent-token>'
Mental Model
Soundsible has three independent concepts:
| Concept | Purpose | Important endpoint |
|---|---|---|
| Library | Persistent downloaded/imported tracks and playlists | GET /api/library |
| Playback queue | In-memory queue consumed by the web player | GET/POST /api/playback/queue |
| Download queue | Background jobs that download tracks into the library | POST /api/downloader/queue |
The active player is normally a browser tab or PWA at /player/ or /player/desktop/. The player registers a device and joins a Socket.IO room:
playback:{scope}:{device_id}
Current scope is default.
Agent HTTP commands emit Socket.IO events into the target device room. If the target browser is not connected, Soundsible can accept the request but the device will not react.
Devices
List devices:
curl http://localhost:5005/api/devices
Example response:
{
"devices": [
{
"device_id": "dev-1775490811387-hu7wrzro8",
"device_name": "Desktop",
"device_type": "desktop",
"scope": "default",
"active_sid": "abc123",
"socket_connected": true,
"last_seen_ts": 1775490830.0
}
]
}
How agents should choose a device:
- Prefer a device with
active_sidset. - Prefer
device_type != "agent"for playback. - If the user says “Desktop”, target the device whose
device_nameis exactlyDesktop, or use itsdevice_id. - If there are no devices with
active_sid, tell the user to open Soundsible in a browser or PWA.
Register an agent device, optional but useful for discovery:
curl -X POST http://localhost:5005/api/devices/register \
-H 'Content-Type: application/json' \
-d '{"device_id":"openclaw-agent","device_name":"OpenClaw","device_type":"agent"}'
Browser players also register over Socket.IO. HTTP registration alone does not make a device controllable; active_sid is the signal that a real Socket.IO player is connected.
Control Playback
Pause a target device:
curl -X POST http://localhost:5005/api/agent/command \
-H 'Authorization: Bearer <agent-token>' \
-H 'Content-Type: application/json' \
-d '{"command":"pause","device_id":"Desktop"}'
Resume the current track on a target device:
curl -X POST http://localhost:5005/api/agent/command \
-H 'Authorization: Bearer <agent-token>' \
-H 'Content-Type: application/json' \
-d '{"command":"play","device_id":"Desktop"}'
Skip to next:
curl -X POST http://localhost:5005/api/agent/command \
-H 'Authorization: Bearer <agent-token>' \
-H 'Content-Type: application/json' \
-d '{"command":"next","device_id":"Desktop"}'
Seek to an absolute position in the current track:
curl -X POST http://localhost:5005/api/agent/command \
-H 'Authorization: Bearer <agent-token>' \
-H 'Content-Type: application/json' \
-d '{"command":"seek","position_sec":75.5,"device_id":"Desktop"}'
Seek uses absolute seconds from the start of the current track. To seek to 1:15, send position_sec: 75. To jump forward or backward, first read /api/playback/state, add or subtract seconds from position_sec, then send the new absolute value.
Supported commands:
{"command":"pause"}
{"command":"play"}
{"command":"next"}
{"command":"seek","position_sec":75.5}
For seek, position_sec is required and must be a number greater than or equal to 0. position_ms is also accepted for millisecond-based clients. The target browser clamps positions beyond the loaded track duration to the end of the track.
If device_id is omitted, Soundsible targets the most recently registered non-agent device, preferring devices with an active socket.
Play Music Now
Play a library track by ID:
curl -X POST http://localhost:5005/api/agent/play \
-H 'Authorization: Bearer <agent-token>' \
-H 'Content-Type: application/json' \
-d '{"track_id":"<library-track-id>","device_id":"Desktop"}'
Play by search query:
curl -X POST http://localhost:5005/api/agent/play \
-H 'Authorization: Bearer <agent-token>' \
-H 'Content-Type: application/json' \
-d '{"query":"Daft Punk One More Time","device_id":"Desktop"}'
Search behavior:
- Soundsible searches the local library first.
- If no library match is found, it searches YouTube Music through yt-dlp.
- The target player receives either a library track payload or a preview payload.
Expected response when sent to a live device:
{
"status": "sent",
"device_id": "dev-...",
"track": {
"id": "track-or-video-id",
"title": "Track title",
"artist": "Artist"
}
}
If the device is registered but offline:
{
"status": "sent",
"device_id": "dev-...",
"warning": "Device appears offline (no active socket)"
}
Treat that as not actually played.
Browser Autoplay Limitation
After a browser reload, the page may have no user activation. In that state, Chrome, Safari, and Firefox can reject audible audio.play() calls that originate from Socket.IO or HTTP-triggered agent commands.
Soundsible handles this by staging the remote request in the player. The target device shows a Remote playback is ready prompt with the requested track. One tap starts the staged playback and unlocks the browser audio session for later agent commands in that page session.
Agent behavior:
- If
/api/agent/playreturnsstatus: "sent"and the device hasactive_sid, the command reached the browser. - If playback does not audibly start immediately after a fresh reload, tell the user to tap the remote playback prompt on that device.
- Once the user has started any audio in that page session, later agent playback, pause, resume, and next commands should work without this prompt.
This is a browser security policy, not a Soundsible API failure. To avoid the prompt completely, use a native playback surface or server-side playback engine instead of browser audio.
Library
Get the full library:
curl http://localhost:5005/api/library
Search library:
curl 'http://localhost:5005/api/library/search?q=radiohead'
The library response includes persistent tracks. A normal track has a stable Soundsible id. Use that id for:
{"track_id":"<id>"}
Useful library routes:
| Method | Path | Purpose |
|---|---|---|
GET | /api/library | Full library metadata |
GET | /api/library/search?q=... | Search local library |
GET | /api/library/albums?sort=&genre=&year= | Albums from the normalized catalog. sort is one of newest, alphabeticalByName, alphabeticalByArtist, byYear, byGenre, random, frequent, recent, highest; an unknown one is a 400 |
GET | /api/library/albums/<album_id> | One record and its track ids, in disc and track order |
GET | /api/library/artists | Artists with a track to their name, from track_artists |
GET | /api/library/artists/<artist_id> | One artist: the tracks they perform on, and the records they are credited with |
GET | /api/library/genres | Genres present, with song and album counts |
GET | /api/library/years | Release years present, with album and track counts |
POST | /api/library/scan | Start an asynchronous scan of configured music roots; optional body {"path":"..."} must stay inside one |
GET | /api/library/scan | Current or last scan state and counters |
GET | /api/library/favourites | Favorite track IDs (only the ones you own a file for) |
GET | /api/library/favourites/entries | All saved songs, downloaded or not: {"version":2,"favourites":[{"keys":[...],"title","artist",...}]} |
POST | /api/library/favourites/toggle | Toggle favorite, body {"track_id":"..."} or {"favourite":{"keys":["yt:<video_id>"],"title":"...","artist":"..."}} |
POST | /api/library/playlists | Create playlist, body {"name":"..."} |
POST | /api/library/playlists/<name>/tracks | Add track to playlist, body {"track_id":"..."} |
DELETE | /api/library/playlists/<name>/tracks/<track_id> | Remove track from playlist |
Mutation routes may require admin authorization or trusted LAN/Tailscale access.
Folder scans require library:write. A successful start returns HTTP 202;
poll the GET route until state is completed or failed. Repeating the
start during queued or scanning returns the same scan_id. Scans are
additive: use the separate purge route when missing source files should be
removed from the catalog.
Playback Queue
This is the queue the browser player consumes when the user or agent asks for “next”.
Read queue:
curl http://localhost:5005/api/playback/queue
Add a library track:
curl -X POST http://localhost:5005/api/playback/queue \
-H 'Content-Type: application/json' \
-d '{"track_id":"<library-track-id>"}'
Add a YouTube preview item:
curl -X POST http://localhost:5005/api/playback/queue \
-H 'Content-Type: application/json' \
-d '{
"preview": {
"video_id": "dQw4w9WgXcQ",
"title": "Example",
"artist": "Artist",
"duration": 213,
"thumbnail": "https://..."
}
}'
Queue operations:
| Method | Path | Purpose | |||
|---|---|---|---|---|---|
GET | /api/playback/queue | Queue contents | |||
POST | /api/playback/queue | Add library track or preview | |||
DELETE | /api/playback/queue/<index> | Remove by queue index | |||
DELETE | /api/playback/queue/track/<track_id> | Remove all queue entries with ID | |||
POST | /api/playback/queue/move | Body {"from_index":0,"to_index":1} | |||
DELETE | /api/playback/queue | Clear queue | |||
GET | /api/playback/next | Pop next item | |||
POST | /api/playback/repeat | Body `{“mode”:“off" | "all" | "one" | "once”}` |
POST | /api/playback/shuffle | Shuffle current queue |
Important: /api/playback/next pops an item. Do not call it just to inspect the queue.
Download Queue
This queue downloads audio into the persistent library. It is not the same as playback queue.
Use this endpoint only when the user wants to download/import music into the library:
POST /api/downloader/queue
The request body is always an object with an items array:
{
"items": [
{
"song_str": "..."
}
]
}
Do not send a bare item object. Do not send a bare video_id. Do not use playback queue preview objects here.
Search YouTube / YouTube Music:
curl 'http://localhost:5005/api/downloader/youtube/search?q=Daft%20Punk%20One%20More%20Time&limit=5&source=ytmusic'
Valid source values:
ytmusic
music
youtube
source=ytmusic and source=music search YouTube Music. source=youtube searches regular YouTube.
Peek a YouTube URL or ID, without queueing or downloading:
curl 'http://localhost:5005/api/downloader/youtube/peek?id=dQw4w9WgXcQ'
Download Queue Item Formats
There are three supported music item shapes.
1. Recommended: resolved YouTube / YouTube Music result
Use this when the agent has searched /api/downloader/youtube/search and selected a result. Send a full YouTube URL in song_str and include video_id.
If the search result has webpage_url, use it. If it only has an 11-character id, build:
https://www.youtube.com/watch?v=<id>
Example:
curl -X POST http://localhost:5005/api/downloader/queue \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <SOUNDSIBLE_ADMIN_TOKEN>' \
-d '{
"items": [
{
"source_type": "ytmusic_search",
"song_str": "https://www.youtube.com/watch?v=FGBhQbmPwH8",
"video_id": "FGBhQbmPwH8",
"display_title": "One More Time",
"display_artist": "Daft Punk",
"duration_sec": 320,
"thumbnail_url": "https://img.youtube.com/vi/FGBhQbmPwH8/mqdefault.jpg",
"metadata_evidence": {
"title": "One More Time",
"artist": "Daft Punk",
"album": "Discovery"
}
}
]
}'
Use source_type: "ytmusic_search" for a result selected from YouTube Music search. Use source_type: "youtube_search" for a result selected from regular YouTube search. Use source_type: "youtube_url" for a user-provided YouTube URL.
Important: explicit YouTube source types require a YouTube URL in song_str. This is invalid:
{
"items": [
{
"source_type": "ytmusic_search",
"song_str": "Daft Punk One More Time"
}
]
}
2. User-provided YouTube URL
Use this when the user gives a YouTube URL directly. source_type is optional because Soundsible can infer YouTube URLs, but including it is clearer.
curl -X POST http://localhost:5005/api/downloader/queue \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <SOUNDSIBLE_ADMIN_TOKEN>' \
-d '{
"items": [
{
"source_type": "youtube_url",
"song_str": "https://www.youtube.com/watch?v=FGBhQbmPwH8",
"video_id": "FGBhQbmPwH8"
}
]
}'
Do not send only the video id:
{
"items": [
{
"video_id": "FGBhQbmPwH8"
}
]
}
That is rejected because the downloader needs song_str to say what to download.
3. Plain text fallback
Use this only when the agent has no resolved YouTube result yet and wants Soundsible to search during processing. Do not set source_type.
curl -X POST http://localhost:5005/api/downloader/queue \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer <SOUNDSIBLE_ADMIN_TOKEN>' \
-d '{
"items": [
{
"song_str": "Daft Punk - One More Time",
"metadata_evidence": {
"title": "One More Time",
"artist": "Daft Punk"
}
}
]
}'
This path is less precise. For agent workflows, prefer searching first, selecting a result, then queueing the resolved URL format.
Accepted Response
Successful queueing returns accepted item IDs:
{
"status": "queued",
"ids": ["download-job-id"],
"accepted": [
{
"index": 0,
"id": "download-job-id",
"source_type": "ytmusic_search"
}
],
"rejected": []
}
Partial failure is possible. Always inspect both accepted and rejected.
Common rejected reasons:
| Reason | Meaning | Fix |
|---|---|---|
No JSON data received | Body was empty or not JSON | Send Content-Type: application/json and a JSON object |
Item must be an object | An items entry was not an object | Put objects inside items |
Missing source_type/song_str | No usable input | Include song_str |
Invalid or unsupported YouTube URL or missing video_id | Explicit YouTube source type had no full YouTube URL/video id | Send full URL in song_str and video_id |
Playlist-only or invalid YouTube URL (missing v=) | URL is not a single video URL | Pick a specific video |
This link type is not supported | Spotify link or unsupported source | Resolve through YouTube Music first |
Start processing:
curl -X POST http://localhost:5005/api/downloader/start \
-H 'Authorization: Bearer <SOUNDSIBLE_ADMIN_TOKEN>'
Check download queue:
curl http://localhost:5005/api/downloader/queue/status
When downloads finish, Soundsible emits library_updated and the track appears in /api/library.
Agent Decision Rules for Downloads
- If the user says “download this YouTube link”, queue
source_type: "youtube_url"withsong_stras the full URL. - If the user says “download Song by Artist”, first call
/api/downloader/youtube/search?source=ytmusic, pick the best result, then queue the resolved URL format. - If search is unavailable or inconclusive, queue plain text with only
song_strand optionalmetadata_evidence; do not addsource_type. - Never use Deezer IDs, Spotify URLs, playback queue preview objects, or raw
video_id-only payloads for/api/downloader/queue. - After queueing, call
/api/downloader/start; queueing alone does not begin processing.
Deezer Discovery
Deezer routes are metadata only.
Allowed proxy paths:
chart
search
playlist/<numeric-id>
track/<numeric-id>
artist/<numeric-id>/top
Examples:
curl 'http://localhost:5005/api/discovery/deezer/search?q=daft%20punk'
curl 'http://localhost:5005/api/discovery/deezer/chart'
curl 'http://localhost:5005/api/discovery/deezer/playlist/3155776842'
Deezer track IDs are not playable Soundsible track IDs. A Deezer row is a metadata suggestion. To play it:
- Extract
titleandartist.namefrom the Deezer response. - Search YouTube Music:
/api/downloader/youtube/search?q=<artist title>&source=ytmusic. - Pick the best result.
- Either:
- play now with
/api/agent/playusing the query, or - add a preview item to
/api/playback/queue, or - add it to
/api/downloader/queueto download it into the library.
- play now with
Do not call /api/agent/play with track_id: "deezer_123" or a raw Deezer numeric ID.
Playback State, Resume, and Handoff
Players periodically write state:
PUT /api/playback/state
State includes:
{
"track_id": "track-or-video-id",
"position_sec": 42.5,
"is_playing": true,
"device_id": "dev-...",
"device_name": "Desktop",
"device_type": "desktop"
}
Players may also publish a session object alongside it — the queue, the
transport preferences and the DJ workspace behind the song — which is
what lets another device pick the session up rather than just the track. It is a
delta: omit the key and whatever is stored for that device is kept, send null
to end it. Agents have no reason to write one, and an agent that starts an
unrelated song on a device should send "session": null so the device does not
restore a queue that track is not in. Sessions over 256 KB are dropped.
Read state:
curl http://localhost:5005/api/playback/state
Read state from another device, excluding the current device:
curl 'http://localhost:5005/api/playback/state?exclude_device=<device-id>'
Move playback from one device to another:
curl -X POST http://localhost:5005/api/playback/handoff \
-H 'Content-Type: application/json' \
-d '{"from_device_id":"dev-phone","to_device_id":"dev-desktop"}'
Handoff behavior:
- Reads state from
from_device_id. - Emits
playback_stop_requestedtoplayback:default:<from_device_id>. - Writes target playback state with
to_device_id. - Emits
playback_start_requestedtoplayback:default:<to_device_id>.
If to_device_id has no active socket, the response includes an offline warning.
Seek behavior:
POST /api/agent/commandwith{"command":"seek","position_sec":...}reads the target device playback state.- The API writes the requested
position_secinto playback state. - The API emits
playback_seek_requestedtoplayback:default:<device_id>. - The browser player sets
audio.currentTimeand persists the new position.
If the target has no current playback state, the API returns 404 with No playback state available for target device.
Podcasts
Podcast discovery and playback is separate from music search.
Useful routes:
| Method | Path | Purpose |
|---|---|---|
GET | /api/discovery/podcasts/search?q=... | Search podcast directory |
GET | /api/discovery/podcasts/top?country=US&limit=25 | Top podcasts |
GET | /api/podcasts/subscriptions | Subscribed feeds |
POST | /api/podcasts/subscribe | Subscribe by feed data |
GET | /api/podcasts/feeds/<feed_id>/episodes | Episodes for a feed |
GET | /api/podcasts/episodes-by-url?rss_url=... | A feed’s show (title, author, image_url) and episodes, without subscribing |
POST | /api/podcasts/enclosure/peek | Create a stream token for an enclosure |
GET | /api/podcasts/stream/<token> | Stream podcast audio |
Podcast preview queue items use enclosure_url, not video_id.
Recommended Agent Skill Flow
For “play X on my desktop”:
- Verify token with
GET /api/agent/verify. - Read devices with
GET /api/devices. - Select
Desktopwithactive_sid; if missing, ask the user to open/player/desktop/. - Call
POST /api/agent/playwith{"query":"X","device_id":"Desktop"}. - If the response has
warning, report that the device is offline.
For “add X to the queue”:
- Search local library:
GET /api/library/search?q=X. - If a good library match exists,
POST /api/playback/queuewith{"track_id":"..."}. - If not, search YouTube Music with
/api/downloader/youtube/search. - Add the chosen result as
{"preview": {...}}.
For “seek to 1:15” or “jump to 75 seconds”:
- Read devices with
GET /api/devicesand target an active browser player. - Send
POST /api/agent/commandwith{"command":"seek","position_sec":75,"device_id":"Desktop"}. - For relative seeking, read
/api/playback/state, compute the new absolute position, then sendseek.
For “download X”:
- Search YouTube Music:
GET /api/downloader/youtube/search?q=X&source=ytmusic&limit=5. - Pick the best result. Use
webpage_urlassong_str; if missing, buildhttps://www.youtube.com/watch?v=<id>. - Queue it with
POST /api/downloader/queueas{"items":[{"source_type":"ytmusic_search","song_str":"https://www.youtube.com/watch?v=<id>","video_id":"<id>","display_title":"...","display_artist":"..."}]}. - Start processing with
POST /api/downloader/start. - Poll
/api/downloader/queue/status. - Refresh library after completion.
For “download this YouTube URL”:
- Queue it with
POST /api/downloader/queueas{"items":[{"source_type":"youtube_url","song_str":"<full-youtube-url>"}]}. - Start processing with
POST /api/downloader/start.
For “play this Deezer track”:
- Treat Deezer as metadata only.
- Search YouTube Music for
artist + title. - Play the YouTube Music match or add it as preview.
Troubleshooting
Check device/socket state:
curl http://localhost:5005/api/agent/debug/socketio \
-H 'Authorization: Bearer <agent-token>'
Common problems:
| Symptom | Meaning | Fix |
|---|---|---|
warning: Device appears offline | Device is registered but has no active Socket.IO room join | Open/reload the player on that device |
Target device not found | Agent used an unknown device ID/name | Call /api/devices and target a real device |
Command returns sent but nothing plays | Usually no active socket or browser autoplay blocked | Check active_sid; after reload, tap the remote playback prompt once |
Seek returns seek requires position_sec | Agent sent seek without an absolute position | Send {"command":"seek","position_sec":75} |
| Seek returns no playback state | Target device has not reported a current track | Start playback first or target the active device from /api/devices |
| Deezer ID fails | Deezer IDs are metadata only | Resolve by title/artist through YouTube Music |
/api/playback/next changed queue | It pops the next item | Use GET /api/playback/queue to inspect |
Download queue rejects raw video_id | /api/downloader/queue requires items[].song_str | Send full YouTube URL in song_str plus video_id |
Download queue rejects ytmusic_search with plain text | Explicit YouTube source types require resolved YouTube URLs | Search first, then queue the selected result URL; or omit source_type for plain text fallback |
| Downloaded song not visible | Download job not finished or library not synced | Check /api/downloader/queue/status, then /api/library/sync if needed |
Minimal OpenAPI-Style Summary
GET /api/health
GET /api/devices
POST /api/devices/register
POST /api/agent/token admin
GET /api/agent/verify agent token
POST /api/agent/play agent token
POST /api/agent/command agent token; pause/play/next/seek
GET /api/agent/debug/socketio agent token
GET /api/library
GET /api/library/search?q=
GET /api/library/albums
GET /api/library/albums/<album_id>
GET /api/library/artists
GET /api/library/artists/<artist_id>
GET /api/library/genres
GET /api/library/years
GET /api/library/favourites
GET /api/library/favourites/entries
POST /api/library/favourites/toggle
POST /api/library/playlists
POST /api/library/playlists/<name>/tracks
GET /api/playback/queue
POST /api/playback/queue
DELETE /api/playback/queue/<index>
DELETE /api/playback/queue/track/<track_id>
POST /api/playback/queue/move
DELETE /api/playback/queue
GET /api/playback/state
PUT /api/playback/state
POST /api/playback/handoff
POST /api/playback/notify-stop
GET /api/downloader/youtube/search?q=&limit=&source=
GET /api/downloader/youtube/peek?id=
POST /api/downloader/queue admin/trusted
GET /api/downloader/queue/status
POST /api/downloader/start admin/trusted
GET /api/discovery/deezer/<allowlisted-path>
GET /api/discovery/podcasts/search?q=
GET /api/discovery/podcasts/top