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 matchor: an array of conditions of which at least one must matchnot: a single condition (orand/orgroup) 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.
Continuing a search
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" } |