Skip to content

IPC Protocol

The daemon protocol is length-delimited JSON over local IPC. Unix builds use Unix-domain sockets. Windows builds use Tokio named pipes. CLI, TUI, scripts, and MCP bridge through the same frame codec and request/response shape.

Platform Transport Notes
macOS Unix-domain socket Resolved under the app support/runtime path for the active instance.
Linux Unix-domain socket Prefers $XDG_RUNTIME_DIR, then /run/user/$uid, then a private /tmp/spotuify-$uid fallback.
Windows Named pipe Uses a \\.\pipe\... path and keeps the next pipe instance ready before handing the accepted stream to a task.

The transport is intentionally below the protocol. A Windows client still sends the same JSON request envelope as a macOS or Linux client.

{
"id": 1,
"payload": {
"type": "Request",
"cmd": "playback-get"
}
}
Terminal window
spotuify status --format json
spotuify devices --format json
spotuify search "quiet storm" --format json
spotuify queue --format json

Representative request variants:

Request CLI surface
ClientSeed TUI/event clients only; cached startup seed
PlaybackGet spotuify status
PlaybackCommand pause, resume, toggle, next, previous, seek, volume, shuffle, repeat
DevicesList spotuify devices
DeviceTransfer spotuify transfer
Search spotuify search
QueueGet spotuify queue
QueueAdd spotuify queue add
PlaylistsList spotuify playlists
PlaylistTracks spotuify playlist tracks
PlaylistAddItems spotuify playlist add
PlaylistRemoveItems spotuify playlist remove
PlaylistRemoveOccurrences / PlaylistRemoveOccurrencesPreview spotuify playlist remove-at; TUI playlist-detail Delete; MCP playlist_remove_occurrences
ArtistAlbums spotuify artist albums
FollowedArtists spotuify artist followed
LibrarySave spotuify like, spotuify save
ShowEpisodes spotuify show episodes
EpisodeFeed spotuify episodes
CoverArt TUI art fetch, spotuify refresh-media
LyricsGet spotuify lyrics show, spotuify lyrics follow, spotuify refresh-media
SubscribeEvents spotuify lyrics follow, TUI/event clients
SetVizEnabled spotuify viz enable/disable
SetVizStyle spotuify viz style <name>; TUI ctrl+v picker; MCP viz_style_set. Validates against VIZ_STYLES, writes viz.style, then broadcasts ClientPreferencesChanged
ThemesList / SetTheme spotuify theme / theme list / theme <name> / theme path; TUI t picker; MCP themes_list / theme_set. Merges built-ins with <config_dir>/themes/*.toml, writes tui.theme, then broadcasts ClientPreferencesChanged carrying the RESOLVED colours so no client reads a theme file. The themes response carries active as a whole spec, not a name: the daemon keeps painting a theme whose file was deleted mid-session, so the active theme is not always in the list. SetTheme, SetVizStyle, and Reload share a write lane so two preference writes cannot leave the config file and the broadcast disagreeing
Reload spotuify reload. Re-reads the config into runtime, then emits ConfigReloaded and ClientPreferencesChanged: clients toast and refetch diagnostics on the former but none re-seed preferences from it, so a hand-edited tui.theme / viz.style would otherwise never reach a running TUI
ReminderCreate / RemindersList / ReminderCancel spotuify reminder ...
NotificationsList / NotificationAct spotuify notifications ...
PlaybackSpeedGet / PlaybackSpeedSet spotuify speed; TUI [ / ]; MCP playback_speed_get / playback_speed_set; macOS speed menu
EqGet / EqSet spotuify eq; TUI E (editor) / Ctrl-e (next preset); MCP eq_get / eq_set; macOS EQ menu
BookmarkCreate / BookmarksList / BookmarkUpdate / BookmarkDelete / BookmarkPlay spotuify bookmark ...; TUI B + screen 8; MCP bookmark_*; macOS Bookmarks
CheckUpdate spotuify update, TUI/app update banners

ClientSeed is deliberately client-specific. It hydrates event-driven clients from cached playback, queue, devices, recent items, and visualizer state. It must not trigger Spotify refreshes; live refreshes belong to daemon warm/sync loops or explicit CLI requests.

refresh-media is a CLI convenience over PlaybackGet, CoverArt, and a force-refresh LyricsGet for the current track. It does not clear existing client media while the new fetch is in flight.

Exact playlist occurrence requests use zero-based positions on the wire:

{
"cmd": "playlist-remove-occurrences-preview",
"playlist": "spotify:playlist:example",
"items": [
{ "uri": "spotify:track:duplicate", "positions": [1, 4] }
]
}

The CLI presents those positions as one-based rows. Preview and write perform the same daemon-authoritative URI/position validation. For providers with playlist version tokens, the write records enough pre-state for position-aware undo.

lyrics follow is a watch client over existing protocol calls. It subscribes to PlaybackChanged, fetches lyrics with LyricsGet on track change, and advances the active lyric line locally from playback time.

ArtistAlbums returns the full discography in one response. The daemon tags each album with album_group (album, single, compilation, or appears-on) and in_library by intersecting against the cached saved-album set. Clients section and filter from that single payload, so the “in library” toggle never needs a refetch. FollowedArtists is cache-backed and falls back to a live fetch when the cache is cold. See JSON Output for the tagged row shape.

EpisodeFeed merges the first page of episodes from followed shows, caches the feed for quick repeat reads, and supports --refresh when you want a live re-fetch. CheckUpdate returns the cached GitHub release observation and an upgrade hint for the current install method; the background daemon loop refreshes that observation on startup and every few hours.

Terminal window
spotuify analytics top --kind tracks --format json
spotuify analytics import lastfm --user your-lastfm-user --from 2024-01-01 --format json
spotuify analytics import status 018f... --format json

Representative request variants:

Request CLI surface
AnalyticsEvents spotuify analytics events
AnalyticsTop spotuify analytics top
AnalyticsHabits spotuify analytics habits
AnalyticsSearch spotuify analytics search
AnalyticsRediscovery spotuify analytics rediscovery
AnalyticsRebuild spotuify analytics rebuild
AnalyticsPrune spotuify analytics prune
AnalyticsImport spotuify analytics import lastfm and the --target lastfm compatibility alias
AnalyticsImportStatus spotuify analytics import status
AnalyticsImportUnresolved spotuify analytics import unresolved
AnalyticsImportUndo spotuify analytics import undo

Last.fm import requests carry optional credentials and date bounds:

{
"type": "analytics-import",
"target": "last_fm",
"username": "your-lastfm-user",
"api_key": "lastfm-api-key",
"from_ms": 1704067200000,
"to_ms": 1735689600000,
"apply": false
}

Use apply: false for preview. The daemon resolves config/env defaults when username or api_key are omitted.

Terminal window
spotuify daemon status --format json
spotuify doctor --format json
spotuify cache status --format json
spotuify reindex --format json

Representative request variants:

Request CLI surface
GetDaemonStatus spotuify daemon status
GetDoctorReport spotuify doctor
Reindex spotuify reindex
CacheStatus spotuify cache status
Sync spotuify sync
LogsTail spotuify logs tail
Reload spotuify reload
Reconnect spotuify reconnect
{
"Ok": {
"data": {
"kind": "Playback",
"playback": {}
}
}
}

Analytics import responses are wrapped in ResponseData variants over IPC. The CLI unwraps these payloads for --format json.

{
"kind": "AnalyticsImportSummary",
"summary": {
"run_id": "018f...",
"provider": "lastfm",
"username": "your-lastfm-user",
"dry_run": true,
"fetched": 1200,
"stored": 0,
"duplicates": 0,
"resolved": 1138,
"promoted": 0,
"unresolved": 62,
"started_at_ms": 1735689600000,
"finished_at_ms": 1735689660000
}
}

Import status, unresolved, and undo responses use:

AnalyticsImportRunStatus { status }
AnalyticsImportUnresolved { entries }
AnalyticsImportUndoSummary { summary }

Errors are typed:

{
"Error": {
"message": "no active device",
"kind": "provider",
"code": "provider",
"retryable": false
}
}

Error kinds:

auth
invalid_request
network
provider
rate_limited
unsupported
internal

The daemon broadcasts state changes so clients do not have to poll forever. spotuify events streams the same broadcast to stdout as JSONL, which is the easiest way to see the contract below in motion:

Terminal window
spotuify events --kind playback-changed --once --timeout 10

Each line carries the daemon’s own fields plus _received_at_ms (the underscore keeps the envelope out of the protocol’s namespace). Lines are re-serialised rather than forwarded byte-for-byte, so key order and number formatting are the CLI’s; field values, including those of an event the CLI cannot decode, are the daemon’s.

shutdown-requested
playback-changed
queue-changed
devices-changed
playlists-changed
library-changed
search-updated
search-page
search-complete
search-failed
event-stream-lagged
sync-started
sync-finished
mutation-finished
analytics-import-progress
rate-limited
auth-error
mutation-accepted
mutation-finalized
schema-compat
player-ready
player-degraded
provider-policy
provider-policy-cleared
premium-required
session-disconnected
player-failed
listen-qualified
operation-recorded
operation-undone
config-reloaded
client-preferences-changed
spectrum-frame
viz-source-changed
reminder-due
reminders-changed
bookmarks-changed
eq-changed
update-available
auth-migration-recommended

provider-policy is the current provider-tagged local-playback restriction event. premium-required remains in the roster only so clients can decode events from released older daemons.

Adding an event kind or field does not bump protocol_version. A daemon therefore always outlives some of its clients, and two rules keep the older client alive:

  1. An unknown event tag decodes, it does not fail. Clients decode a kind they don’t know into a fallback (DaemonEvent::Unknown in Rust, .unknown in Swift) that keeps the raw frame, log it, and carry on reading. Before this, one unknown tag failed the frame, killed the connection, and sent the client into a reconnect loop.
  2. A new field on an existing event must be optional. Extra fields are ignored for free; a missing required field is a decode error. In Rust that means #[serde(default)] (and skip_serializing_if where the field is optional on the wire); in Swift, decodeIfPresent.

The fallback records why it fired, because the two cases call for opposite reactions:

Reason Meaning What a client does
unknown-tag A kind absent from the roster: a daemon newer than this client Log at debug, ignore. Nothing was lost.
undecodable-known-tag A kind the client knows, in a shape it could not read (rule 2 broken) Warn, and treat push state as stale: the TUI refreshes, MCP still invalidates the resources that tag invalidates.

Never default a missing required field into a plausible value. An auth-migration-recommended whose flag failed to decode must degrade, not recommend the wrong command.

The event roster is a two-way contract: DaemonEvent::all_kind_labels() in spotuify-protocol is compared against clients/macos/Tests/SpotuifyKitTests/Fixtures/event-kinds.json, and the macOS test bundle checks its own decoder against the same fixture, so a kind added on either side fails until the other catches up.

Import progress events are daemon-owned and broadcast to subscribers:

{
"type": "analytics-import-progress",
"run_id": "018f...",
"provider": "lastfm",
"username": "your-lastfm-user",
"phase": "resolving",
"fetched": 1200,
"stored": 800,
"resolved": 760,
"promoted": 760,
"unresolved": 40,
"message": "resolving Last.fm scrobbles"
}