Search API Documentation

A search turns a plain-English request, optionally combined with a structured filter, boost and sort, into a ranked list of tracks from one library. Every search is stored, so you can read it back later or continue it in conversation.

Creating a search calls the library’s language model synchronously, so a POST normally takes a few seconds. The response includes the matching tracks with their scores.

Endpoints

GET /api/v1/libraries/{library_id}/searches

Retrieve the searches run in a library, oldest first. The list gives a summary of each search; fetch a single search for its results.

Path Parameters

Name Type Description Required
library_id integer ID of the library Yes

Query Parameters

Name Type Description
page integer Page number for pagination (default: 1)
per_page integer Records per page, up to 200 (default: 25)
remote_identifier string List only the searches with this identifier
archived boolean true lists only the searches that are archived, false only those that are not
since string List only the searches created at or after this time, such as 2026-09-01 or 2026-09-01T14:30:00-05:00

Response

{
    "pagination": {
        "total_entries": 25,
        "per_page": 25,
        "current_page": 1,
        "total_pages": 1
    },
    "searches": [
        {
            "id": 1,
            "library_id": 2,
            "lookup": "6f1c2a4e-2f6b-4b8e-9d5e-3f0c8a7b1d21",
            "remote_identifier": "search_123",
            "original_search_id": null,
            "search_params": {
                "nls": "Find me some upbeat rock songs",
                "filter": {
                    "and": [
                        {"genre": {"value": "Rock"}},
                        {"active": {"value": true}}
                    ]
                },
                "sort": [["total_score", "desc"], ["title", "asc"]]
            },
            "total_rows": 15,
            "overflow": false,
            "archived": false,
            "error_message": null,
            "created_at": "2024-08-19T13:30:26.166-05:00",
            "updated_at": "2024-08-19T13:36:56.251-05:00",
            "url": "https://app.spondulix.ai/api/v1/libraries/2/searches/1"
        }
    ]
}

Response Fields

Field Type Description
id integer Unique identifier for the search
library_id integer ID of the library
lookup string Opaque identifier used by the web interface
remote_identifier string Client-provided identifier
original_search_id integer ID of the parent search for continued conversations
search_params object The search document as sent: nls, filter, boost and sort
total_rows integer Number of tracks that matched
overflow boolean Whether more tracks matched than were stored (up to 200 are kept)
archived boolean Whether the search has been archived in the web interface
error_message string Why the search produced no results, if the model could not build a query
url string URL of the search

HTTP Response Codes

Code Description
200 OK Successful request
404 Not Found Invalid library ID

POST /api/v1/libraries/{library_id}/searches

Run a new search and store it.

Path Parameters

Name Type Description Required
library_id integer ID of the library Yes

Request Body

Wrap the fields in a search key.

Field Type Description Required
search_params object The search document (see below). May also be sent as a JSON string. Yes
remote_identifier string Your own identifier for the search, up to 128 characters No
original_search_id integer ID of an earlier search to continue (see Continuing a search) No
ai_prompt_name string Name of the library prompt to use. Defaults to the library’s most recently updated prompt. Only a search with nls uses a prompt. No
ai_prompt_version_id integer A specific prompt version to use instead of the prompt’s current version No
provider string AI provider for this search: openai, deepseek or grok. Defaults to the library’s chat provider. The library must hold a key for it. No
llm_model_name string Chat model for this search. Defaults to the library’s chat model. Must be one the provider offers for the library’s key. No

The search_params document holds up to four keys; any other key is dropped. At least one of nls or filter is needed. A boost orders what those find and finds nothing by itself.

Key Type Description
nls string The natural language search, up to 128 characters
filter object Hard constraints every result must satisfy
boost object Weighted preferences that raise a track’s score without excluding others
sort array Sort order, as [field, direction] pairs

Example request:

curl -X POST https://app.spondulix.ai/api/v1/libraries/2/searches \
  -H "Authorization: Bearer your-api-token-here" \
  -H "Content-Type: application/json" \
  -d '{
    "search": {
      "remote_identifier": "client_search_123",
      "search_params": {
        "nls": "upbeat rock songs about the open road",
        "filter": {
          "and": [
            { "active": { "value": true } },
            { "not": { "mood": { "value": "Sad" } } }
          ]
        },
        "boost": {
          "instrumental": { "value": false, "weight": 20 }
        },
        "sort": [["total_score", "desc"], ["title", "asc"]]
      }
    }
  }'

Response

{
    "id": 42,
    "lookup": "6f1c2a4e-2f6b-4b8e-9d5e-3f0c8a7b1d21",
    "original_search_id": null,
    "library_id": 2,
    "remote_identifier": "client_search_123",
    "ai_prompt_version_id": 7,
    "provider": "openai",
    "llm_model_name": "gpt-4o",
    "search_params": {
        "nls": "upbeat rock songs about the open road",
        "filter": {
            "and": [
                { "active": { "value": true } },
                { "not": { "mood": { "value": "Sad" } } }
            ]
        },
        "boost": {
            "instrumental": { "value": false, "weight": 20 }
        },
        "sort": [["total_score", "desc"], ["title", "asc"]]
    },
    "query_seconds": 2.33,
    "model_answer": {
        "explanation": "Upbeat rock with lyrics about the road; excluded sad tracks as asked.",
        "query": {
            "filter": { "and": [{ "genre": { "value": "rock" } }, { "not": { "mood": { "value": "sad" } } }] },
            "boost": {
                "mood": { "value": "upbeat", "weight": 50 },
                "subgenre": { "in": ["folk", "irish/celtic"], "weight": 30, "resolved": [{ "asked": "irish folk", "matched": [{ "name": "folk", "similarity": 0.91 }, { "name": "irish/celtic", "similarity": 0.9 }] }] },
                "lyrical_theme": { "value": "the open road", "weight": 40 }
            }
        }
    },
    "llm_interactions": [
        {
            "id": 388,
            "user_prompt": "upbeat rock songs about the open road",
            "automatic": false,
            "is_valid": true,
            "has_error": false,
            "error_message": null,
            "finish_reason": "stop",
            "tokens_in": 2140,
            "tokens_out": 96,
            "created_at": "2026-09-29T10:14:22.301-05:00",
            "query_time": 2.02,
            "assistant_response": { "explanation": "...", "query": { "filter": {}, "boost": {} } }
        }
    ],
    "initial_prompt_chain": [
        { "role": "system", "content": "You turn a request for music into a search ..." }
    ],
    "sql_query": "SELECT tracks.id, ... FROM tracks WHERE ...",
    "total_rows": 15,
    "overflow": false,
    "archived": false,
    "error_message": null,
    "created_at": "2024-08-19T13:30:26.166-05:00",
    "updated_at": "2024-08-19T13:30:29.577-05:00",
    "searches_tracks": [
        { "track_id": 999, "score": 95.5, "title": "Drunken Daisy", "bpm": 132, "duration": 185 },
        { "track_id": 1042, "score": 88.0, "title": "Two Lane Summer", "bpm": null, "duration": 178 }
    ],
    "url": "https://app.spondulix.ai/api/v1/libraries/2/searches/42"
}

Response Fields

In addition to the fields in the list response:

Field Type Description
ai_prompt_version_id integer | null The prompt version the search used; null for a filter-only search
provider string The AI provider the search used
llm_model_name string The chat model the search used
query_seconds number How long the search took in seconds, including the model call, to two places
model_answer object | null The model’s final reading of a request in natural language: its explanation and the query it wrote. A tag item in the query whose name was matched by meaning carries resolved, a list of { "asked", "matched": [{ "name", "similarity" }] }, which answers why the search looked for folk when the request said “irish folk”. Null for a search that had no such request, or when the model gave no usable answer.
llm_interactions array Each exchange with the model, in order (see below)
initial_prompt_chain array | null What the model was sent before the request: the prompt and its examples
sql_query string | null The SQL that ran
searches_tracks array Matching tracks, best first, each with enough of the track to list the results without asking for it
track_id integer External ID of the track
score number Total score of the track
title string Title of the track
bpm integer | null Beats per minute
duration integer | null Duration in seconds

Each entry of llm_interactions:

Field Type Description
id integer ID of the interaction
user_prompt string What was sent
automatic boolean Whether it was sent by the search itself, to have the model correct an answer, and not by the user
is_valid boolean Whether the answer was well formed
has_error boolean Whether the answer could not be used
error_message string | null Why not
finish_reason string Why the model stopped
tokens_in, tokens_out integer Tokens sent and received
created_at string Timestamp
query_time number Seconds the model took, to two places
assistant_response object | null What the model answered

These make a search’s JSON a good deal longer than the list’s summary; fetch one search when you want to see what the model did with it.

If the model could not turn the request into a query, for example because it named a tag the library does not have and could not recover, the search is still stored with error_message set and no tracks.

HTTP Response Codes

Code Description
201 Created The search ran and was stored
404 Not Found Invalid library ID
422 Unprocessable Content Invalid request (see below)

A 422 body names the field at fault:

{ "search_params": ["must not be empty"] }
{ "search_params": ["must be an object with nls, filter, boost and/or sort"] }
{ "search_params": ["is not valid JSON: unexpected end of input"] }
{ "ai_prompt_name": ["\"nope\" was not found"] }
{ "ai_prompt_version": ["must be chosen for a natural language search"] }
{ "provider": ["is not configured for this library"] }
{ "llm_model_name": ["is not included in the list"] }

An ai_prompt_version error means the library has no prompt yet, so it cannot run a natural language search; a filter-only search still works. Prompts are written in the web interface and listed by the prompts endpoint.

Filter Structure

A filter is an object whose keys are field names, each holding an operator and a value:

{ "genre": { "value": "Rock" } }

Combine conditions with and, or and not (upper or lower case):

  • and: an array of conditions that must all match
  • or: an array of conditions of which at least one must match
  • not: a single condition (or and/or group) that must not match

A bare value is shorthand for value, so { "genre": "Rock" } means { "genre": { "value": "Rock" } }.

Every condition is strictly true or false for every track, so not is the set complement: tracks with no value for the field land on the not side. { "is_null": true } finds tracks without a value and { "is_not_null": true } those with one. A condition with a null or empty value is reported as an error rather than treated as “anything”.

See the operators reference for the fields, the operators each type accepts and the null rules.

Boost Structure

A boost has the same shape as a filter, with a weight on each entry. It does not exclude tracks; it adds the weight to the score of every track that matches, so tracks satisfying more of the request rise to the top. The natural language search is itself turned into a boost by the model, and any boost you send is combined with it.

{
    "boost": {
        "genre": { "value": "Rock", "weight": 50 },
        "bpm": { "value": 120, "weight": 30 },
        "lyrical_theme": { "value": "driving all night to get home", "weight": 40 }
    }
}

Range fields such as bpm and duration score by distance, so a track at 118 BPM gets most of a weight centred on 120. lyrical_theme scores by how closely a track’s lyrics match the phrase in meaning.

Sort Structure

The sort is an array of [field, direction] pairs, applied in order:

"sort": [["total_score", "desc"], ["title", "asc"]]

total_score is the combined score from the boosts. Any built-in field or single-valued custom metadata type can be sorted on; a type that allows multiple values cannot. Tracks without a value for a field always sort last, whatever the direction. The shorter forms ["bpm desc", "-duration", "title"] are also accepted.

Pass an earlier search’s id as original_search_id to continue it. The model sees the earlier request and its own reply, so a follow-up such as “the same, but slower and no vocals” refines the earlier search rather than starting over. The new search is stored separately with its own results.

{
    "search": {
        "original_search_id": 42,
        "search_params": { "nls": "the same, but slower and no vocals" }
    }
}

GET /api/v1/libraries/{library_id}/searches/{id}

Retrieve a search and its results.

Path Parameters

Name Type Description Required
library_id integer ID of the library Yes
id integer ID of the search Yes

Response

Returns the search object as shown in the create response, including searches_tracks and every exchange with the model.

HTTP Response Codes

Code Description
200 OK Successful request
404 Not Found Invalid library ID or search ID

DELETE /api/v1/libraries/{library_id}/searches/{id}

Delete a search and its results. Takes the editor role.

Path Parameters

Name Type Description Required
library_id integer ID of the library Yes
id integer ID of the search Yes

Response

(no response body)

HTTP Response Codes

Code Description
204 No Content Successful deletion
403 Forbidden Your role does not allow editing
404 Not Found Invalid library ID or search ID

POST /api/v1/libraries/{library_id}/searches/{id}/save_as_example

Keep the search’s request and the model’s answer (model_answer) as the next example of the prompt the search ran with. The quickest way to a good example is a search that read a request well. No body. Takes the editor role.

Path Parameters

Name Type Description Required
library_id integer ID of the library Yes
id integer ID of the search Yes

Response

Returns the new example, as the examples endpoint shows one, with a Location header.

HTTP Response Codes

Code Description
201 Created The example was made
403 Forbidden Your role does not allow editing
404 Not Found Invalid library ID or search ID
422 Unprocessable Content { "message": "This search has no answer from the model to save as an example" }