Command Line

spondulix is the command line for Spondulix Search: load and maintain a music catalog, manage its metadata, and search it in plain language, all through the API.

$ spondulix search "upbeat indie pop about a road trip, around 120 bpm"
Search 42: upbeat indie pop about a road trip, around 120 bpm
15 tracks matched (3.41s).

#  SCORE  ID    TITLE                 BPM  LENGTH
1  95.5   1001  Highway Windows Down  118  3:24
2  88.0   1042  Two Lane Summer       122  2:58
...

Install

With Go 1.22 or later:

$ go install github.com/mdchaney/spondulix-cli/cmd/spondulix@latest

Or from a checkout of the repository:

$ make install        # into $(go env GOPATH)/bin
$ make build          # or just build ./bin/spondulix

For completion in your shell, see spondulix completion --help.

Sign in

There are two ways to sign in.

With your API token

Your API token is on your account page. The CLI reads it from standard input so that it stays out of your shell history:

$ spondulix auth login --with-token
API token (from https://app.spondulix.ai/my/account):
Signed in to https://app.spondulix.ai with an API token.
$ spondulix auth login --with-token < token.txt

An API token may do anything you may, and works until you reset it on your account page.

With your browser

$ spondulix auth login
Opening your browser to sign in. If it does not open, visit:

  https://app.spondulix.ai/oauth/authorize?...

Waiting for you to approve the request...
Signed in to https://app.spondulix.ai. The CLI may: read your catalog, run searches and change your catalog.

You sign in to Spondulix in the browser and approve the CLI there. Nothing secret is typed into the terminal. The CLI gets only the permissions you approve, its access lasts an hour at a time and is renewed as needed, and you can disconnect it at any time from your account page or with spondulix auth logout.

It asks to read your catalog, to run searches and to change your catalog. It does not ask to administer your libraries unless you pass --admin. That permission lets it make libraries and change their settings, their AI provider keys and who may use them, and change your own account and password: more than loading and searching a catalog needs. A command that needs it and has not got it says so, and how to sign in again with it.

Flag  
--admin Also ask for permission to administer your libraries and your account
--no-browser Print the address instead of opening a browser. On a machine without one, open the address on another machine, approve, and paste the address that browser ends up at (it starts http://127.0.0.1 and will not load) back into the terminal.
--scopes catalog,search Ask for less than everything. Without catalog_write the CLI can read and search but not change anything.
--new-client Register the CLI with the server again. For when the server has forgotten it.

A server from before its API accepted this kind of sign-in lets you sign in and then refuses the result. The CLI tells you when that happens, and to sign in with your API token instead.

Other servers

$ spondulix auth login --with-token --host spondulix.example.com

The first server you sign in to becomes the default. Credentials are kept for each server separately; --host, or SPONDULIX_HOST, chooses between them. A server is known by its address as you gave it, so localhost:3000 and 127.0.0.1:3000 are two servers, each with a sign-in of its own.

For a server on your own machine, plain http is assumed:

$ spondulix auth login --with-token --host localhost:3000

Checking and signing out

$ spondulix auth status
https://app.spondulix.ai
  Signed in with:   an API token
  Signed in as:     Floyd Lawson <[email protected]>
  Default library:  2
  Server:           up, and it accepts these credentials

$ spondulix auth logout

Choose a library

Almost everything happens in a library. Choose one once:

$ spondulix libraries list
ID  NAME             ROLE    TRACKS  PROMPTS  CHAT MODEL  DESCRIPTION
2   Frivolous Music  editor  22148   3        gpt-4o      Production music for film and television

$ spondulix libraries use "Frivolous Music"
Now working in library 2, Frivolous Music.

Or name one each time with --library (-l), by ID or by name, or with SPONDULIX_LIBRARY.

Your role in a library is what you may do there. A viewer can read and search; changing anything takes an editor, an admin or the owner.

Commands

   
auth login, logout, status, token Sign in and out
account show, update, password, reset-token, sessions, apps Your own account
config list, get, set, unset, path Settings
ping Check the server and your credentials
libraries list, get, create, update, use, models Your libraries
providers list, get, add, update, remove, available The AI providers a library has keys for
collaborators list, get, update, remove Who may use a library
invitations list, get, rescind Invitations not yet taken up
tracks list, get, create, update, delete, import, lyrics The catalog
composers ..., publishers ... list, get, create, update, delete, import and tracks, for the people and companies credited
metadata-types list, get, create, update, delete, sort, fields The fields of its own a library tags tracks with
metadata-tags list, get, create, update, delete, bulk, tracks The values of a tag type; name the type with --type
pros list, get Performing rights organizations
search Search a library
searches list, get, keep, delete Searches that were run
prompts list, get, create, update, delete, copy, check, versions What the language model is told
examples list, get, create, update, delete, hide, unhide, sort, suggest, suggested The worked examples a prompt is sent with; name the prompt with --prompt
embeddings Whether the lyrics are ready to be searched by meaning
api Any request to the API

Every command has --help, with examples.

Records

Tracks, composers, publishers and tags are addressed by your IDs, which you choose when you create them. Tags of a type that has no external IDs are addressed by name. Libraries, metadata types and prompts have IDs the server gave them, and can be named instead.

$ spondulix composers create --id 125 --name "Johann Sebastian Bach" --birth-date 1685-03-31
$ spondulix composers update 125 --pro 35 --ipi 116
$ spondulix composers get 125
$ spondulix composers delete 125

$ spondulix metadata-tags list --type genre
$ spondulix metadata-tags get "hip hop" --type mood

list takes --query (-q) for tracks, composers, publishers and PROs. Composers and publishers are matched on their names; tracks on every word of the query, in their title, description, lyrics, tags and credits. It finds records by what is written in them. To find music by what it is like, use search.

$ spondulix composers list -q jones
$ spondulix tracks list -q "thousand feet"

tracks lists the tracks of a composer, a publisher or a tag:

$ spondulix composers tracks 186
$ spondulix metadata-tags tracks rock --type genre

update changes only what you name. To remove a value, set it to nothing: an empty string for text (--description ""), or null with --set (--set bpm=null).

delete asks first. A script must pass --yes.

Tracks

Besides the fields every track has, a track carries a value for each of the library’s metadata types. Set those with --set, under the type’s name:

$ spondulix tracks create --id 1001 --title "Highway Windows Down" \
    --bpm 118 --duration 204 --lyrics @lyrics.txt \
    --style-alike haim --style-alike "best coast" \
    --composer 186:50 --composer 187:50 \
    --set genre_name="indie pop" \
    --set 'mood_names=["upbeat", "summer"]' \
    --set release_date=2019-04-12
   
--set genre_id=3 A tag, by your ID for it
--set genre_name=rock A tag, by name
--set 'mood_names=["calm","warm"]' The tags of a type that allows several. The list replaces the ones the track had.
--set 'genres=[{"id": 9, "name": "ska"}]' A tag by ID and name, created if it is new
--set rating=4.5 A value of any other type
--set rating=null No value

A tag that is named and is not there is created if its type has no external IDs. If it has, a new tag needs an ID as well as a name, which is the fourth form. A key that is no field and no metadata type is refused, so a misspelling does not pass for success.

The value of --set is JSON when it parses as JSON, so 120, true, null and ["a","b"] mean what they say, and text otherwise. To send text that looks like JSON, quote it as JSON: --set 'title="123"'.

--composer and --publisher take ID or ID:SHARE. Shares left out are split evenly. The list replaces the track’s credits.

--lyrics, --lyrics-chorus and --description read a file when given @FILE.

Anything can also be given as JSON, with the fields the API documents. Flags override what is in it:

$ spondulix tracks create --data @track.json
$ spondulix tracks update 1001 --data '{"bpm": 124, "genre_name": "rock"}'

spondulix metadata-types fields lists every field of a library, its own and the built-in ones.

Searching

Say what you want:

$ spondulix search "sad songs for a rainy montage, no vocals"

The library’s language model turns the request into a query. That takes a few seconds, and costs the library what a search on the site does, since it runs through the library’s own AI provider.

Every search is kept. Refine one by continuing it:

$ spondulix search --continue 42 "the same, but slower"

A filter, a boost and a sort can go alongside the request, and a filter can take its place. A filter is a hard constraint; a boost raises the score of the tracks that match without leaving the others out, so it needs a request or a filter to go with. Both are JSON, as the operators reference describes.

$ spondulix search "songs for a montage" --filter '{"instrumental": true}'
$ spondulix search --filter @filter.json --sort "bpm desc" --sort title
$ spondulix search "driving" --boost '{"lyrical_theme": {"value": "leaving a small town", "weight": 40}}'
Flag  
--filter, --boost JSON, or @FILE
--sort FIELD, "FIELD desc" or -FIELD. May be repeated.
--continue ID Refine an earlier search
--prompt NAME The library prompt to use. spondulix prompts list shows them.
--provider, --model Instead of the library’s own
--limit N Results to show. 20 unless you say; 0 for all.
--remote-id Your own identifier for the search. cli unless you say.

Searches run from the CLI are marked cli in the library’s search history, and spondulix searches list --from cli lists them.

To see what the model made of a request, and what ran:

$ spondulix searches get 42 --answer     # its explanation, and the query it wrote
$ spondulix searches get 42 --sql        # the SQL that ran
$ spondulix searches get 42 -o json      # everything, each exchange with the model included

Prompts and examples

A prompt is what the library’s language model is told, to turn a request into a query. It is sent with worked examples: requests, each with the answer the model should give. Examples are how the model learns a catalog’s vocabulary.

$ spondulix prompts create --name main           # starts as one written from the library's fields
$ spondulix prompts get main --text              # as the model sees it
$ spondulix prompts update main --prompt @prompt.txt --note "Say what a boost is for"
$ spondulix prompts check main                   # what it and its examples get wrong

Changing what a prompt says makes a new version and makes it the current one. The others are kept:

$ spondulix prompts versions list main
$ spondulix prompts versions get main 34 --diff
$ spondulix prompts versions make-current main 33

To try a change without touching the prompt in use, spondulix prompts copy main --name experiment, and search with --prompt experiment.

The quickest way to a good example is a search that read a request well:

$ spondulix search "hard rock songs"
$ spondulix searches keep 228                    # now an example of the prompt it ran with

Or have the model write requests of its own and run them, then keep the ones it read well and throw the rest away. This costs a search for each:

$ spondulix examples suggest --prompt main --count 5 --focus "trailer editors"
$ spondulix examples suggested --prompt main
$ spondulix searches keep 230
$ spondulix searches delete 231

Examples can also be written by hand, put in order, and hidden, which keeps them but stops sending them:

$ spondulix examples create --prompt main --request "upbeat rock" --answer @answer.json
$ spondulix examples hide 31 --prompt main
$ spondulix examples sort 40 31 35 --prompt main

Loading a catalog

In this order, since each refers to the one before:

$ spondulix metadata-types create --name genre --data-type tag --include-in-prompt --has-external-id
$ spondulix metadata-types create --name mood --data-type tag --multiple --include-in-prompt --has-external-id=false

$ spondulix metadata-tags bulk --type genre genres.json
$ spondulix metadata-tags bulk --type mood moods.txt

$ spondulix composers import composers.json
$ spondulix publishers import publishers.json
$ spondulix tracks import tracks.jsonl
$ spondulix embeddings --wait

A tag type has external IDs unless you say --has-external-id=false. With them, every tag needs your ID for it and is addressed by it. Without, tags are addressed by name, and tracks can bring new ones with them (--set mood_names=...).

Files

import and bulk read JSON: either a list of records or one record after another (JSON Lines). - reads standard input. Records have the fields the API documents, and each needs your ID for it, as id.

{"id": 1001, "title": "Highway Windows Down", "bpm": 118, "genre_name": "indie pop",
 "tracks_composers": [{"composer_id": 186, "share": 100}]}

metadata-tags bulk also reads a plain list of names, one to a line.

What list writes, import reads. To copy a library’s tracks into another:

$ spondulix tracks list --all -o jsonl -l 2 | spondulix tracks import - -l 5

How import behaves

--mode  
upsert The default. A record that exists is updated; one that does not is created.
create A record that exists is reported as a failure, and left as it was.
update Changes records that exist; creates nothing.
  • The whole file is checked before anything is sent: a record without an ID, or two with the same one, and nothing is changed. --dry-run stops there.
  • With --mode upsert, tracks are sent 200 to a request and composers and publishers 1000. With --mode create or update they go one to a request.
  • A record that fails does not stop the rest, unless you pass --fail-fast. The failures are listed at the end, by their place in the file, and the exit code is 1.
  • Loading a file again is safe.
  • It stops at once if the server refuses your credentials or your role.

Requests are made one after another. --concurrency makes several at once, up to 32, which may be quicker against a server with the means to answer several at once. Against the application in development, 800 tracks took the same 40 seconds either way.

A server from before October 2026 is not safe with it when records create tags on the way: two records that bring the same new tag at the same moment can each create it, and the library ends up with the tag twice. Against such a server, load tags, composers and publishers first, as above.

metadata-tags bulk is all or nothing: if a tag is invalid none is saved. The server takes 1000 tags in a request, so a longer file is sent as several, each all or nothing on its own.

Lyrics

Lyrics are embedded for searching by meaning in the background, in batches, after tracks are loaded. Until a track’s lyrics are embedded, searches that go by what lyrics are about do not find it.

$ spondulix embeddings
Lyric chunks: 4 embedded, 10882 waiting, 0 in progress.
The lyrics are not all ready to be searched yet.

--wait keeps looking until nothing is waiting or in progress, which is what a script that loads a catalog and then searches it wants.

Setting up a library

$ spondulix libraries create --name "Frivolous Music"
$ spondulix libraries use "Frivolous Music"
$ spondulix providers add openai < key.txt
$ spondulix libraries models
$ spondulix libraries update "Frivolous Music" \
    --chat-provider openai --chat-model gpt-4o \
    --embedding-provider openai --embedding-model text-embedding-3-large

Then its metadata types, its tracks and a prompt, as above.

Searches in plain language, and the embedding of lyrics, run through the library’s own accounts with its AI providers and are charged to them. A key is given once and never shown again; what is shown of it is its last four characters. It is read from the terminal without being shown, or from standard input, never from a flag, so that it stays out of your shell history.

People join a library by invitation, with a role. Invitations are sent on the web site: an invitation is mail from Spondulix to an address of the sender’s choosing, which is not something a token that has leaked should be able to do. The CLI lists those not yet taken up, and rescinds them.

Role May
viewer Read and search
editor Also change the catalog, prompts and examples
admin Also change the library’s settings, its AI providers and who may use it
owner The one the library belongs to
$ spondulix invitations list
$ spondulix collaborators list
$ spondulix collaborators update [email protected] --role admin
$ spondulix collaborators remove [email protected]

Three things are done on the web site and not here, by the server’s decision: making an account, deleting a library, and inviting someone to one.

With a browser sign-in, everything in this section but the lists needs spondulix auth login --admin.

Your account

$ spondulix account show
$ spondulix account update --name "Floyd Lawson"
$ spondulix account password
$ spondulix account sessions list                # the browsers you are signed in from
$ spondulix account apps list                    # the applications signed in as you

spondulix account reset-token replaces your API token. The old one stops working at once, everywhere it is used. If the CLI is signed in with the API token it takes the new one in place of the old and goes on working. Otherwise the new token is printed, once; the server does not show it again.

With a browser sign-in, changing anything here needs spondulix auth login --admin.

Output

--output (-o) chooses the format:

   
table The default. For reading. Long values are cut short.
json What the server sent. A list is a JSON array of records.
jsonl A record to a line
csv With a header row, and every field rather than only the table’s columns

Data goes to standard output, and everything said about it (what was created, how many more pages there are, progress) to standard error, so output can be piped as it is:

$ spondulix tracks list --all -o json | jq '.[] | select(.bpm > 120) | .title'
$ spondulix composers list --all -o csv > composers.csv

Lists show a page of 25. --page and --per-page (200 at most) choose another; --all reads every page; --limit N reads the first N records.

Scripts

$ export SPONDULIX_TOKEN=...          # instead of signing in
$ export SPONDULIX_HOST=https://app.spondulix.ai
$ export SPONDULIX_LIBRARY=2
$ spondulix tracks get 1001 -o json
Exit code  
0 Success
1 An error, including an import in which some records failed
2 The command was misused
3 Not signed in, or the server refused the credentials
4 Not allowed: your role in the library, or the token’s permissions
5 Not found
6 The server rejected the data as invalid
130 Interrupted

The server describes its whole API in OpenAPI, at /api/v1/openapi.yaml (or /api/v1/openapi.json, the same document), and holds every response to it; that document is the contract the CLI is written to:

$ spondulix api /api/v1/openapi.yaml > openapi.yaml

For what the commands do not cover, spondulix api makes any request with your credentials:

$ spondulix api libraries/2/tracks --query per_page=5
$ spondulix api libraries/2/composers --data '{"composer": {"external_id": 125, "name": "J. S. Bach"}}'
$ curl -H "Authorization: Bearer $(spondulix auth token)" https://app.spondulix.ai/api/v1/ping

API versions

The API has a version, and this program is built for one. It declares it with every request, and the server says how the two stand:

$ spondulix version
spondulix 1.0.0 (API 1.1.0)
https://app.spondulix.ai speaks API 1.1.0: the same as this program

When the server is newer, every command ends with a note to upgrade. When the server is older than this program, nothing is said while things work; when something fails, the error comes with a note that the server may not have it yet, which is what a 404 from a server awaiting a deploy means. version asks without credentials, so it works before signing in, and auth status and --debug show both versions too.

--debug logs every request and response to standard error. It does not log your token, and keys, passwords and tokens in what is sent and received are left out of it.

Requests that are safe to repeat are tried three times when the server is unreachable or answers 502, 503 or 504. Creating something, and running a search, is never repeated: it may have happened.

Settings

Environment Setting Flag  
SPONDULIX_HOST host --host The server. https://app.spondulix.ai unless you say.
SPONDULIX_LIBRARY library --library, -l The library, kept for each server
SPONDULIX_TOKEN     A token to use instead of the stored credentials
SPONDULIX_CONFIG_DIR     Where the files are kept
    --timeout How long to wait for an answer. 2m unless you say.

A flag overrides the environment, which overrides the setting.

$ spondulix config set host spondulix.example.com
$ spondulix config list

Settings are in config.json and credentials in credentials.json, in $SPONDULIX_CONFIG_DIR, else $XDG_CONFIG_HOME/spondulix, else ~/.config/spondulix. credentials.json is readable only by you. It holds your tokens as they are, not encrypted: anyone who can read your files can use them, as with ~/.netrc or an SSH key without a passphrase. On a shared machine, prefer the browser sign-in, whose tokens can be disconnected from your account page, or keep the token in SPONDULIX_TOKEN.