HTTP API Reference

Every endpoint served by Lowkey Media Server.

All paths are relative to your server's address

Version 2.31.0

Conventions

All paths below are relative to wherever your server is reachable: its host and port, or the address of a reverse proxy in front of it. The port is 10111 unless changed with LOWKEY_PORT or the port config key. Request bodies are JSON unless a table says otherwise. Responses are JSON with Content-Type: application/json unless a table says otherwise. Booleans in query strings are the literal strings true and false.

Endpoints reject unsupported methods with 405. A path parameter written {id} is a single path segment.

Access Levels

Every endpoint is marked with one of three access levels.

LevelRequirement
PublicNo credential.
ReadA credential, unless the allowPublicAccess setting is on, in which case anonymous requests are accepted. Endpoints marked Read that also perform writes require a credential for the write path regardless of the setting.
AdminA credential. Every authenticated user is an admin.

A credential is one of: an auth_token cookie set by POST /auth/login; an Authorization: Bearer <token> header carrying a JWT or an lk_ API key; or an X-API-Key: <key> header. Headers are checked before the cookie.

A request that fails authentication receives 401 with a plain-text body when the request carries Accept: application/json. Otherwise it receives a 302 redirect to /login. API clients should send the Accept header. A request authenticated as the temporary default admin account before first-run setup is complete receives 403 with body {"error":"setup_required"}.

Errors

There is no single error envelope. Handlers under /api/ that were written for the web client return {"error": "<message>"}. Older handlers return a plain-text message with the status code. Status codes follow HTTP semantics:

StatusMeaning
400Malformed JSON, missing required field, invalid parameter, or a validation failure.
401Missing or invalid credential.
403Path outside every configured storage root; setup required; or cross-origin form post.
404Unknown job, workflow, model, file, or person.
405Method not supported on this path.
409Move destination already belongs to another item.
413Upload or image body exceeded its cap.
500Database or filesystem failure.
503Auto scheduler not running; SSE connection limit reached.

Media Paths

A media item is identified by its path: the absolute filesystem path for local roots, or s3://bucket/key for object storage roots. Paths are compared as stored. Endpoints that serve or process a file refuse paths outside every configured storage root with 403. Paths in query strings must be URL-encoded.

Authentication

MethodEndpointAccessDescription
POST/auth/loginPublicExchange credentials for a JWT.
POST/auth/logoutPublicClear the session cookie.
GET/auth/statusPublicReport the current credential.
GET/auth/usersPublic*List users.
POST/auth/usersPublic*Create a user.
DELETE/auth/users?username=Public*Delete a user.
GET/auth/keysAdminList API keys.
POST/auth/keysAdminCreate an API key.
DELETE/auth/keys?id=AdminRevoke an API key.
GET, POST/auth/cli/authorizePublicBrowser approval page for the CLI login flow.
POST/auth/cli/tokenPublicExchange a CLI authorization code for an API key.

* /auth/users is registered without middleware. Creating a user is open only while no real user exists (first-run); after that the caller must be authenticated or the response is 403. The server refuses to delete the last remaining user.

POST /auth/login

{"username": "alice", "password": "..."}

Response 200: {"status": "ok", "token": "<jwt>", "setup_required": false}. The same JWT is set as an auth_token cookie (HttpOnly, SameSite=Lax, 24-hour expiry). The JWT itself is valid for one year. Invalid credentials return 401 with {"error":"Invalid credentials"}. setup_required is true when the login used the temporary default admin.

GET /auth/status

With a valid credential: {"loggedIn": true, "username": "alice", "publicAccess": false, "defaultStartPath": "..."}. When the credential is the cookie, the response also carries "token" with the session's JWT. Without a credential: {"loggedIn": false, "publicAccess": false}.

POST /auth/users

{"username": "bob", "password": "..."}

Response 201: {"status": "created"}. GET returns {"users": [...]}. DELETE returns {"status": "deleted"}.

POST /auth/keys

{"name": "ci", "username": "alice"}

username defaults to the caller. Response 201: {"status": "created", "key": "lk_...", "id": 3, "name": "ci", "username": "alice", "prefix": "lk_ab12"}. The plaintext key is returned once. GET returns {"keys": [{"id", "username", "name", "prefix", "created_at", "last_used_at"}]} with Unix-second timestamps. DELETE returns {"status": "deleted"}.

CLI authorization flow

GET /auth/cli/authorize?port=&state=&code_challenge=&name= renders an approval page for the browser session's user. It redirects to /login when there is no session and returns 403 while the default admin is the only account. POST of the same form with action=approve redirects to http://127.0.0.1:{port}/callback?code=&state=; action=deny redirects with error=access_denied. Cross-origin posts are rejected. The code expires after two minutes.

POST /auth/cli/token with {"code": "...", "code_verifier": "..."} verifies the PKCE challenge (S256) and returns {"status": "ok", "key": "lk_...", "username": "alice", "name": "lokictl@host"}. Invalid, expired, or mismatched codes return 400.

System

MethodEndpointAccessDescription
GET/healthPublicLiveness, SSE stats, job counts by state.
GET/api/statsReadLibrary statistics and metadata coverage.
GET/api/configAdminActive configuration with secrets redacted.
POST/configAdminReplace the configuration and apply it.
POST/api/db/queryAdminRead-only SQL.
POST/api/db/loadReadWeb boot handshake. Acknowledges with {}; the web client cannot switch databases.
GET/api/prompts/describeAdmin{"prompt": "..."}, the active description prompt.
GET/ollama/modelsAdmin{"models": [...]} from the configured Ollama instance.
POST/openAdmin{"path": "..."}. Opens an absolute path with the host's default application. Returns {"status": "ok"}.
POST/api/embedding/directml/installAdminInstall the DirectML runtime for GPU inference. Windows only.

GET /health

{
"status": "healthy",
"timestamp": 1704067200,
"stream": {"active_connections": 2, "total_messages": 150, "max_connections": 1000,
"dropped_broadcasts": 0, "dropped_client_msgs": 0, "rejected_connections": 0},
"jobs": {"total": 10, "pending": 2, "in_progress": 1, "completed": 5, "cancelled": 1, "error": 1}
}

Fully permissive CORS headers are set on this endpoint.

GET /api/stats

Statistics are computed as a snapshot after startup and refreshed in the background. Until the first snapshot exists the response is {"ready": false}. Otherwise it is an object of counts keyed by metadata type (descriptions, transcripts, hashes, dimensions, tags, embeddings, faces) with totals.

GET /api/config, POST /config

GET returns every configuration key with secrets replaced by a redaction marker and storage-root credentials omitted. POST accepts the full configuration object, validates dbPath as non-empty, writes the file, and applies changes that do not need a restart. Because GET redacts secrets, a client that round-trips GET into POST must not send redacted values back; the CLI strips them. Set secrets and storage roots from the web Config page.

POST /api/db/query

{"sql": "SELECT path FROM media WHERE path LIKE ?", "args": ["%/2024/%"], "limit": 100, "timeout_ms": 5000}
FieldDefaultDescription
sqlrequiredOne SELECT or WITH statement.
args[]Positional values for ? placeholders.
limit1000Row cap. Maximum 10000.
timeout_ms5000Statement timeout. Maximum 30000.

Response: {"columns": [...], "rows": [[...], ...], "row_count": n, "truncated": false, "elapsed_ms": 3}. The connection is opened with PRAGMA query_only, so writes fail at the SQLite level. Errors return {"error": "..."} with 400.

Jobs

MethodEndpointAccessDescription
POST/createAdminCreate a job.
GET/jobs/listAdminAll jobs as a JSON array.
GET/api/jobs/for-path?path=AdminJobs whose input or resources name a path: {"path", "jobs": [...]}.
POST/job/{id}/cancelAdminCancel. Response 200, plain text.
POST/job/{id}/pauseAdminPause at the next item boundary. 400 if the job cannot be paused.
POST/job/{id}/resumeAdminResume a paused job.
POST/job/{id}/copyAdminClone into a new pending job. Response 201: {"id", "message"}.
POST/job/{id}/removeAdminDelete the job record. Response 200, plain text.
POST/jobs/clearAdminDelete every job in a terminal state. Response: {"cleared_count": n, "message": "..."}.
GET/tasksAdminRegistered tasks and their option schemas.

POST /create

{"input": "describe --apply missing D:/photos/2024", "fields": {"prompt": "Describe the scene."}}

input is split on spaces with double-quote grouping. The first token is the task id, the last token is the job input, and the tokens between are the task's arguments. A one-token input is a task with no input. The splitter has no escape syntax; a token containing a double quote cannot be expressed. fields is an optional map appended to the arguments as --key value pairs without splitting, for values containing spaces, quotes, or newlines. Empty keys and empty values are skipped.

Response 201: {"id": "<uuid>"}. The job starts as soon as a runner is free. Task ids and options are listed by GET /tasks.

Job Object

FieldTypeDescription
idstringUUID.
commandstringTask id.
argumentsstring[]Argument tokens.
inputstringJob input.
original_inputstringThe input as submitted, before any workflow substitution.
hoststringHostname of the server that ran the job.
resourcesstring[]Paths the job declared it touches.
dependenciesstring[]Ids of jobs that must complete first.
stateinteger0 pending, 1 in progress, 2 completed, 3 cancelled, 4 error, 5 paused.
created_at, claimed_at, completed_at, errored_atRFC 3339Zero value when unset.
output_files, source_filesstring[]Parallel arrays: files produced and the source each came from.
workflow_idstringNon-empty when the job was created by a workflow.
progress_done, progress_totalintegerPer-item progress for per-item tasks.
interrupt_countintegerTimes the job was found still in progress at startup (crash or unclean shutdown). Auto-resume stops after a cap so a job that crashes the server does not loop.

Job stdout is not part of the object. It is streamed over /stream while the job runs and is not persisted, except by tasks that copy their log into the job record (the cleanup tasks).

A job whose dependency fails or is cancelled is cancelled. A job in progress when the server stops is returned to pending on restart.

Tasks

GET /tasks returns {"tasks": [{"id", "name", "options": [...]}]} sorted by id. Each option:

FieldDescription
nameArgument name. Passed as --name value.
labelDisplay label.
typestring, bool, enum, multi-enum, or number.
choicesAllowed values for enum types.
defaultValue used when omitted.
requiredWhether the task fails without it.
descriptionHelp text.

The task list is summarized in Available Tasks.

Workflows

MethodEndpointAccessDescription
POST/workflowAdminRun a DAG without saving it.
GET/workflowsAdminSaved workflows as a JSON array.
POST/workflows/createAdminSave a workflow. Response 201 with the saved object.
GET/workflows/{id}AdminOne saved workflow.
PUT/workflows/{id}AdminUpdate name and DAG. Returns the updated object.
DELETE/workflows/{id}AdminDelete. Response 204.
POST/workflows/{id}/runAdminInstantiate as jobs. Response 201: {"ids": [...]}.

DAG node

FieldTypeDescription
idstringNode id, unique in the DAG.
commandstringTask id.
argumentsstring[]Argument tokens.
inputstringJob input.
dependenciesstring[]Node ids that must complete first.
pos_x, pos_ynumberEditor layout. Optional.

POST /workflow body: {"tasks": [node, ...]}. POST /workflows/create and PUT /workflows/{id} body: {"name": "...", "dag": [node, ...]}. POST /workflows/{id}/run body: {"input": "..."}; when non-empty, the input is injected into root nodes (nodes with no dependencies). Each node becomes one job; node ids are rewritten to job UUIDs and dependencies are mapped accordingly.

Real-Time Events

MethodEndpointAccessDescription
GET/streamPublicServer-Sent Events for job lifecycle, output, and progress.
GET/api/deps/models/progressAdminServer-Sent Events for model download progress.

/stream responds with text/event-stream, Cache-Control: no-cache, and permissive CORS. A comment line : keep-alive is sent every 30 seconds. At most 1000 concurrent connections are accepted; further connections receive 503. Each client has a 50-message buffer; messages that cannot be delivered within 5 seconds are dropped and counted in /health.

Event nameData
create, update, delete{"updateType": "create"|"update"|"delete", "job": {...}, "html": "<tr>...</tr>"}. job is the job object; for delete only id is populated. html is the rendered table row for the admin page.
stdout-{jobId}{"updateType": "stdout", "line": "..."}. One event per output line while the job runs.
progress{"updateType": "progress", "id": "<jobId>", "done": n, "total": n}.
const es = new EventSource(serverUrl + '/stream');
es.addEventListener('update', (e) => console.log(JSON.parse(e.data).job.state));
es.addEventListener('stdout-' + jobId, (e) => console.log(JSON.parse(e.data).line));

The event stream is not authenticated. It carries job metadata and output lines. Do not expose the port without a proxy that restricts /stream if that is a concern.

Media Library

Composable Query

MethodEndpointAccessDescription
POST/api/media/queryReadPredicate query with optional similarity ranking.
POST/api/mediaReadTag intersection or union. Predecessor of /api/media/query.
{
"mode": "AND",
"predicates": [
{"type": "tag", "value": "sunset"},
{"type": "tag", "value": "blurry", "exclude": true},
{"type": "path", "value": "/2024/"},
{"type": "similar", "value": "D:/photos/x.jpg", "text": "at night", "textWeight": 0.5}
]
}
FieldDescription
modeAND or OR. How predicates combine when a predicate has no join.
predicates[].typeSee the table below.
predicates[].valuePredicate operand. Meaning depends on type.
predicates[].excludeNegate the predicate.
predicates[].joinAND or OR; overrides mode for this predicate's connection to the previous one.
predicates[].text, textWeightVisual predicates only. Blend a text concept into the query vector. textWeight is 0 to 1; default 0.5.
predicates[].nodesVisual predicates only. Composite blend; see below. When present, text is ignored.
predicates[].blendModeReserved for composite blends.
TypeValueSemantics
tagTag labelItem has the tag.
categoryCategory labelItem has any tag in the category.
pathSubstringpath LIKE %value%.
descriptionSubstringdescription LIKE %value%.
hashSubstringhash LIKE %value%.
orientationlandscape, portrait, squareWidth versus height. Items without dimensions never match.
facesungroupedItem has at least one face with no person and no face with a person.
similarMedia pathRank by cosine similarity to the item's embedding.
visualFree textRank by text-to-image similarity (SigLIP 2).
clipImage data URLRank by similarity to an uploaded image (data:image/...;base64,...).
faceMedia path or image data URLRank by face-identity similarity to the faces in the given item or image.

The four ranking types are visual predicates. Evaluation is filter-first: when every predicate is joined by AND, the SQL predicates run first and similarity is scored only within that set. Otherwise similarity produces up to 1000 candidates that are then filtered. When a visual predicate is present, results are ordered by descending score, with the highest score kept when several visual predicates match the same item. Without one, results are ordered by path.

Composite blend nodes

{"type": "similar", "value": "D:/photos/x.jpg", "nodes": [
{"kind": "text", "value": "at night", "weight": 0.5},
{"kind": "text", "value": "blurry", "weight": 0.3, "negative": true},
{"kind": "image", "value": "D:/photos/y.jpg", "weight": 0.7},
{"kind": "clip", "value": "data:image/png;base64,...", "weight": 0.4}
]}
FieldDescription
kindimage (library path), clip (data URL), or text.
valuePath, data URL, or text.
weight0 to 1. Omitted means 1.
negativeSubtract this node's direction.

The predicate's own value is the anchor with weight 1. Every component is normalized, combined into one vector by signed weight, and scanned once. Mixed text and image blends use the multimodal model. A face predicate does not accept nodes.

Result item

FieldDescription
pathMedia path.
width, heightPixel dimensions or null.
elo, battlesBattle Mode rating and bout count.
mtimeMsReserved; 0.
tagLabel, weight, timeStampPopulated when a tag predicate drives the row; otherwise null or 0.

POST /api/media

{"tags": ["sunset", "beach"], "mode": "EXCLUSIVE"}

mode EXCLUSIVE (default) requires every tag; any other value requires any tag. An empty tags array returns the whole library ordered by path. Result items have the same shape as above.

MethodEndpointAccessDescription
POST/api/media/searchRead{"description": "..."}. Description substring match. Optional tags and filteringMode narrow by tag.
GET/api/media/similar?path=&limit=ReadItems similar to a library item. limit default 50. Returns [{"path", "score"}].
GET/api/media/search/visual?q=&limit=ReadText-to-image search. limit default 50. Returns [{"path", "score"}].
POST/api/media/search/imageReadBody is the raw image bytes (any common format, 32 MB cap). Returns ranked result items.
POST/api/media/search/faceReadBody is the raw image bytes (32 MB cap). Detects faces in the image and returns face hits.

score is cosine similarity in the active embedding model's space. /api/media/similar embeds the file on the fly when the item has no stored vector. A face hit is {"faceId", "path", "score", "x", "y", "w", "h", "frameTs", "personId", "model"} with a normalized bounding box.

Item Data

MethodEndpointAccessDescription
POST/api/media/metadataRead{"path"}{"width", "height", "size", "description", "transcript", "hash", "duration"}. duration is present for video.
POST/api/media/tagsRead{"path"}{"tags": [{"label", "category", "weight", "timeStamp"}]}.
POST/api/media/gif-metadataRead{"path"}{"frameCount", "duration"} via ffprobe, or null.
POST/api/media/previewRead{"path", "cache", "timeStamp"}. Returns the thumbnail path string for the requested cache column, generating it if missing, or null. cache is a column name (below) or false to skip.
GET/api/faces?path=ReadFaces detected in one item. See People and Faces.
GET/api/jobs/for-path?path=AdminJobs touching the item.

Mutation

MethodEndpointAccessDescription
POST/api/media/descriptionAdmin{"path", "description"}. Empty string clears. Returns {}.
POST/api/media/transcriptAdmin{"path", "transcript"}. Returns {"status": "ok"}.
POST/api/media/ratingAdmin{"path", "elo"?, "views"?, "wins"?, "losses"?}. Omitted fields are unchanged; with only path the call reads. Returns {"path", "elo", "views", "wins", "losses"}.
POST/api/media/battleAdmin{"winnerPath", "loserPath", "outcome"?}. Records a Battle Mode bout and updates both Elo ratings server-side. outcome is the winner's score: 1 (default) or 0.5 for a draw. Returns {"winnerPath", "winnerElo", "winnerMatches", "loserPath", "loserElo", "loserMatches"}.
POST/api/media/deleteAdmin{"path"}. Removes the item and every referencing row. The file is not deleted.
POST/api/media/forgetAdmin{"path"}. Same effect as delete; named for clarity in scripts.
POST/api/media/moveAdminRe-point database references after a move on disk.
POST/api/media/merge-metadataAdmin{"paths": [target, source, ...]}. Merge tags, embeddings, transcript, and faces from the sources into the first path, then remove the sources.

delete and forget return {"path", "media": n, "tags": n, "embeddings": n, "faces": n, "battles": n} with the count of rows removed per table.

POST /api/media/move

{"from": "C:/pics/2023", "to": "D:/archive/2023", "prefix": true, "dryRun": false}

With prefix, every item whose path begins with from followed by a separator is re-pointed. Matching is segment-aligned. dryRun runs the transaction and rolls it back. Response: {"from", "to", "prefix", "dryRun", "items": n, "rows": {"media": n, "tags": n, ...}, "total": n, "paths": [...], "truncated": false}. When a destination already belongs to another item the response is 409 with the conflicting paths and nothing is changed.

merge-metadata returns {"target", "sources", "tags", "embeddings", "transcript", "transcriptFile", "deleted", "failed", "facesRemoved"}.

Thumbnails

MethodEndpointAccessDescription
POST/api/thumbnailsRead{"path"}[{"cache", "path", "exists", "size"}] for the 600 and 1200 caches.
POST/api/thumbnails/regenerateAdmin{"path", "cache", "timeStamp"}. Regenerates one thumbnail and returns its path.
GET/media/thumbnail?path=&cache=&ts=ReadServe a thumbnail image, generating it on first request.

cache is one of thumbnail_path_100, thumbnail_path_600 (default), or thumbnail_path_1200. timeStamp / ts is the video frame time in seconds. For S3 roots the thumbnail is stored under the root's thumbnail prefix. Responses carry an ETag and honour If-None-Match with 304.

File Serving

MethodEndpointAccessDescription
GET/media/file?path=ReadServe the file. Local roots stream with range support, a 1-hour Cache-Control, and an ETag. S3 roots redirect to a presigned URL (1-hour expiry). Files over 2 GB return 413. Directories return 400.
GET/media/hls?path=&check=ReadHLS status for a video: {"status": "ready", "url": "/media/hls/{hash}/master.m3u8"}, {"status": "idle"}, or an in-progress state. A GET without check=true starts transcoding when renditions do not exist.
DELETE/media/hls?path=AdminDelete cached renditions for the video.
GET/media/hls/{hash}/{file}ReadPlaylists (.m3u8) and segments (.ts).
GET/media/facecrop?id=&size=ReadJPEG crop of one detected face by face id.
GET/static/{file}PublicEmbedded static assets.
GET/app/ReadThe embedded web viewer (single-page application).

Legacy Media Browser

These endpoints back the server-rendered gallery at /media and the browser extensions. New integrations should use the Media Library endpoints.

MethodEndpointAccessDescription
GET/media/api?offset=&limit=&q=AdminPaged items: {"items": [...], "has_more", "total_count"}. limit default 25, maximum 100. q matches filename, description, and tags.
GET/media/api?path=&single=trueAdminOne item by path.
GET/media/suggest?kind=&prefix=&limit=ReadAutocomplete. kind is filters, tag, category, path, or pathdir. limit default 25, maximum 200. Returns {"suggestions": [...]}; tag also returns {"tags": [{"label", "category"}]}.
POST/media/tagAdmin{"media_path", "tag_label", "category_label", "action": "add"|"remove"}. Returns {"status": "ok"}.
GET/media/has-tag?media_path=&tag_label=&category_label=Read{"has_tag": bool}.

Item objects from /media/api use the older shape: {"path", "description", "size", "hash", "width", "height", "tags": [{"label", "category"}], "exists", "duplicateCount"} where nullable columns are encoded as {"String", "Valid"} or {"Int64", "Valid"} pairs.

Taxonomy

A category groups tags. An assignment attaches a tag to a media item, optionally at a video timestamp and with a weight. Tag labels are unique across the library; a tag belongs to one category.

Read

MethodEndpointAccessDescription
GET/api/taxonomyRead[{"label", "weight", "tagViewMode", "tags": [{"label", "category", "weight"}]}], every category with its tags.
GET/api/taxonomy/categoriesRead[{"label", "weight", "description", "tagViewMode"}].
GET/api/taxonomy/tags?category=&excludeCategory=ReadTags in one category, or all tags outside a comma-separated list of categories.
GET/api/taxonomy/tag?label=Read{"label", "category", "weight", "description", "thumbnail_path_600"}.
GET/api/taxonomy/category-count?category=&cap=ReadDistinct media count for a category, as a bare integer. cap stops counting early.
GET/api/tags/list?category=Read{"tags": [{"label", "category", "weight", "media_count"}]}.
POST/api/tags/countRead{"label"}{"count": n}.
POST/api/tags/previewRead{"label"} → the tag's preview thumbnail path or null.

Tags

MethodEndpointAccessBodyDescription
POST/api/tagsAdmin{"label", "categoryLabel", "weight"?}Create. Returns {"label"}.
DELETE/api/tagsAdmin{"label", "categoryLabel"}Delete the tag and its assignments.
POST/api/tags/renameAdmin{"label", "newLabel"}Rename.
POST/api/tags/moveAdmin{"label", "categoryLabel"}Move to another category.
POST/api/tags/orderAdmin{"labels": [...]}Set sort order by assigning ascending weights.
POST/api/tags/weightAdmin{"label", "weight"}Set sort weight.
PUT/api/tags/timestampAdmin{"mediaPath", "tagLabel", "oldTimestamp", "newTimestamp"}Move a video tag timestamp.
DELETE/api/tags/timestampAdmin{"mediaPath", "tagLabel", "timestamp"}Remove a video tag timestamp.

Mutation endpoints in this section return {} on success unless noted.

Categories

MethodEndpointAccessBodyDescription
POST/api/categoriesAdmin{"label"}Create. Returns {"label"}.
DELETE/api/categoriesAdmin{"label"}Delete the category, its tags, and their assignments.
POST/api/categories/renameAdmin{"label", "newLabel"}Rename.
POST/api/categories/tag-view-modeAdmin{"label", "mode"}Set how the viewer lists the category's tags.

Assignments

MethodEndpointAccessBodyDescription
POST/api/assignmentsAdmin{"mediaPath" | "mediaPaths": [...], "tagLabel", "categoryLabel", "timeStamp"?, "applyTagPreview"?}Tag one or many items. mediaPaths takes precedence. applyTagPreview sets the item as the tag's preview image.
DELETE/api/assignmentsAdmin{"mediaPath" | "mediaPaths": [...], "tag": {"tag_label", "time_stamp"}}Remove an assignment. time_stamp selects one timestamped assignment; 0 removes the untimed one.
POST/api/assignments/weightAdmin{"mediaPath", "tagLabel", "weight", "mediaTimeStamp"?}Set the weight of one assignment.

Embeddings Index

Vectors are stored per model in the database and loaded into an exact in-memory cosine index for the active model at startup and after each embed job.

MethodEndpointAccessDescription
GET/api/index/statusAdminIndex and storage state.
GET/api/index/modelsAdmin{"models": [{"id", "name", "active", "indexed", ...}]}.
POST/api/index/rebuildAdminReload the in-memory index. Returns {"status": "ok", "model", "vectors"}. Blocks for the duration.
GET/api/index/missing?model=&limit=Admin{"model", "total_missing", "paths": [...]}. limit default 100, maximum 10000, 0 for count only.
GET/api/embeddings?path=&model=&vector=Admin{"path", "embeddings": [...]}, one row per model unless model is given. vector=true includes the float array.
DELETE/api/embeddings?path=&model=Admin{"deleted": n}.
POST/api/embeddings/pruneAdminDelete vectors whose media row is gone. {"pruned_rows", "pruned_paths"}.
DELETE/api/embeddings/all?confirm=trueAdminDelete every vector. {"deleted": n}. 400 without confirm.
GET/api/embeddings/projection?model=&limit=Admin3D PCA projection for the visualizer: {"model", "dim", "total", "count", "variance", "paths", "points"}. limit default 4000, maximum 20000, stride-sampled.

GET /api/index/status

{
"index": {"installed": true, "model": "siglip2-base-patch16-224", "vectors": 51234},
"active_model": "siglip2-base-patch16-224",
"media_total": 52000,
"missing_active_model": 766,
"orphaned": 12,
"embeddings": [{"model": "...", "count": 51234, "dim": 768, "bytes": 157390848}],
"total_count": 51234,
"total_bytes": 157390848
}

People and Faces

A face is one detection with an embedding. A person is a cluster of faces. Curation state (locks, rejections, bans) is permanent across re-clustering.

MethodEndpointAccessDescription
GET/api/peopleRead[{"id", "name", "coverFaceId", "faceCount", "mediaCount", "lockedCount", "createdAt", "models"}].
POST/api/peopleAdmin{"name"}. Create an empty person. Returns {"id", "name"}.
POST/api/people/{id}/renameAdmin{"name"}.
POST/api/people/{id}/mergeAdmin{"intoId"}. Move every face into another person and delete this one. Returns {"merged", "into"}.
DELETE/api/people/{id}?deleteFaces=&ban=AdminDelete the person. deleteFaces=true also deletes its faces. Otherwise the group is banned from re-forming unless ban=false. Returns {"deleted", "facesDeleted"} or {"deleted", "banned"}.
GET/api/people/{id}/mediaReadMedia items containing the person.
GET/api/people/{id}/facesRead{"personId", "faces": [...]}, least typical first.
POST/api/people/{id}/coverAdminChoose the cover face. Returns {"personId", "coverFaceId"}.
POST/api/people/{id}/lockAdminConfirm every current face. Returns {"personId", "locked": n}.
POST/api/people/lock-allAdminConfirm every face of every named person. Returns {"people", "locked"}.
POST/api/people/{id}/curateAdmin{"keepFaceIds": [...]}. Lock the listed faces and reject the rest. Returns {"personId", "kept", "rejected"}.
POST/api/media/assign-personAdmin{"path", "personId" | "newPerson": true, "name"?, "setCover"?}. Assign every face in a media item to a person.
POST/api/media/reject-personAdmin{"path", "personId" | "name"}. Reject every face in the item from the person. Returns {"personId", "rejectedFaceIds"}.
GET/api/faces?path=Read{"model", "scanned", "scannedAt", "faces": [...]} for one item.
GET/api/faces/{id}/similarReadFace hits ranked by identity similarity to one face.
POST/api/faces/{id}/assignAdmin{"personId" | "newPerson": true, "name"?}. Confirmed assignment. Returns {"faceId", "personId", "created", "name"}.
POST/api/faces/{id}/unassignAdminDetach the face from its person. Returns {"faceId", "unassigned": true}.
POST/api/faces/{id}/rejectAdmin{"personId"}. Permanent veto: the face can never rejoin that person.
GET/api/faces/ungrouped?faces=1&limit=&offset=Read{"count"}; with faces=1 also "faces": [...]. limit default 120, maximum 500.
GET/api/faces/statsRead{"faces": [{"model", "count", "bytes"}], "total_faces", "total_bytes", "scans", "people": {"total", "named", "unnamed"}}.
GET, POST/api/faces/tuningAdmin{"thresholdOffset", "minCluster", "minQuality"}. POST merges the supplied fields and returns the full object.
DELETE/api/faces/all?confirm=trueAdminDelete all faces and people. Returns {"deleted": true}.

A face object carries id, a normalized bounding box (x, y, w, h), frameTs for video, personId, score (detection confidence), assignedBy (cluster or user), and model. Face crops are served by /media/facecrop?id=.

Storage and Transfer

MethodEndpointAccessDescription
POST/api/fs/listRead{"path"}. Empty path lists the storage roots. Returns {"entries": [{"name", "path", "isDir", "mtimeMs", "size", "type"}], "parent", "roots"}. Paths outside every root return 403.
POST/api/fs/scanRead{"path", "recursive"}. Lists media files: {"library": [{"path", "mtimeMs", "size"}], "cursor"}. For admins, new files are also added to the library. cursor is the index of the requested file when path is a file.
POST/api/uploadAdminMultipart upload.
GET/api/exportAdminStream a .lokiexport archive of all tagged media with their tags, embeddings, and faces. Content-Type: application/gzip, sent as an attachment. 400 when nothing is tagged.
POST/api/importAdminMultipart: file (the archive), destRoot (root label, default root when omitted). Paths are rebased onto the destination root. Returns {"imported", "skipped", "files", "dest_root", "warnings"}. 50 GB cap.

POST /api/upload

Form fieldDescription
filesOne or more file parts. Required.
destinationDirectory inside a storage root. Default: uploads/ under the default root.
autoIngestfalse to skip the ingest job queued after upload.

Response: {"success": true, "files": ["<stored path>", ...], "message"}. The body cap is 10 GB. For S3 roots, files are staged locally and uploaded to the bucket.

Dependencies

MethodEndpointAccessDescription
GET/api/deps/statusAdminEvery bundled binary, optional tool, and downloadable model with its state.
POST/api/deps/models/{id}/downloadAdminStart a download. 202 with a progress object; 200 {"state": "installed"} when already installed; 404 for an unknown id.
POST/api/deps/models/{id}/cancelAdminCancel an active download. 202; 404 when none is active.
POST/api/deps/models/{id}/verifyAdminChecksum installed files: {"id", "files": {"<rel_path>": "ok" | "<error>"}}.
DELETE/api/deps/models/{id}AdminRemove a model. 204.
GET/api/deps/models/progressAdminServer-Sent Events; each event's data is a progress object.
GET/api/onboarding/stateAdmin{"shown", "dismissed_at"} for the first-run tour.
POST/api/onboarding/dismiss, /api/onboarding/resetAdminDismiss or re-arm the tour.

A status entry is {"id", "category", "name", "feature", "description", "state", "version", "size_bytes", "path", "error", "detail"}. A progress object is {"id", "state", "current_file", "bytes_done", "bytes_total", "error", "started_at"}. Model ids: wd-eva02-large-tagger-v3, siglip2-base-patch16-224, dinov2-base, yunet, sface, anime-head, ccip, faster-whisper. Downloads resume and are verified against pinned SHA-256 checksums.

Swipe

MethodEndpointAccessDescription
GET/swipeReadThe Swipe progressive web app.
GET/swipe/manifest.jsonPublicPWA manifest.
GET/swipe/apiReadFeed items: {"items": [...], "has_more"}.
ParameterDescription
modeOmitted: seeded shuffle. similar: ranked by similarity to anchor. feed: personalized feed built from likes, keyed by session. text: text-to-image search on q. face: same-person ranking around anchor.
qSearch text (shuffle filter, or the query for mode=text).
anchorMedia path. Required for similar and face. Excluded from its own results.
seedInteger shuffle seed. Same seed, same order.
sessionClient session id for mode=feed. Default default.
orientationlandscape, portrait, or square.
offset, limitPaging. limit default 20, maximum 50.

Settings and Session

The web viewer persists its preferences and per-session UI state on the server through these endpoints. Values are free-form JSON.

MethodEndpointAccessDescription
GET/api/settingsReadThe settings object.
PUT/api/settingsRead (write requires credential)Merge the body into settings.
GET/api/sessionReadThe whole session object.
GET/api/session/{key}ReadOne value.
PUT/api/sessionRead (write requires credential)Merge an object into the session.
PUT/api/session/{key}Read (write requires credential)Set one value to the body.
DELETE/api/sessionRead (write requires credential)Clear the session.
POST/api/session/keysAdmin{"keys": [...]}. Delete the named keys.

Auto Scheduler

The auto scheduler runs metadata tasks over the library when the machine is idle and pauses them when it is not.

MethodEndpointAccessDescription
GET/api/schedulerAdminStatus object.
POST/api/scheduler/modeAdmin{"mode": "off" | "auto"}. Returns the status object.
POST/api/scheduler/runAdminForce one pass now regardless of idle state. Returns the status object.

Status: {"mode", "forced", "state", "reason", "jobId", "done", "total", "ops", "cpuPercent", "sessionLocked", "fullscreenBusy", "onBattery", "inputIdleSeconds"}. state is disabled, waiting, running, yielding, or complete. All three endpoints return 503 when the scheduler is not running.

Visualizations

MethodEndpointAccessDescription
GET/viz/AdminIndex of visualizations.
GET/viz/embeddingsAdmin3D embedding point cloud.
GET/viz/treemapAdminLibrary treemap by folder.
GET/api/treemap?metric=&depth=&limit=&path=AdminTree data rooted at path. limit default 100, maximum 2000 children per node.
GET/api/treemap/metricsAdmin{"metrics": [{"id", "label", "count"}]}, the sizes a treemap can be drawn by.

See also /api/embeddings/projection under Embeddings Index.

Setup Wizard

These endpoints serve the first-run wizard at /setup. They are open until setupComplete is set in the config; after that they require an admin credential.

MethodEndpointDescription
GET/setup/api/state{"setupComplete", "authed", "hasRealUsers", "os", "homeDir", "dataDir", "defaultDBPath", "activeDBPath", "roots", "deps", "modelGroups"}.
POST/setup/api/browseList a directory on the server host for the folder picker.
POST/setup/api/mkdirCreate a directory.
POST/setup/api/databaseChoose or create the SQLite database file.
POST/setup/api/storage/test-s3Test S3 credentials and bucket access.
POST/setup/api/storageSave the storage roots.
POST/setup/api/completeMark setup complete.

HTML Pages

Server-rendered administration pages. All require an admin credential except /login.

PathDescription
/Home: library stats, metadata coverage, ingestion, job queue.
/jobsJob list.
/job/{id}Job detail with live output.
/loginSign-in form. ?redirect= returns to a page; ?setup=true forces account creation.
/configConfiguration editor (GET renders; POST saves, see System).
/editorJob composer.
/mediaLegacy gallery.
/setupFirst-run wizard.
/app/The full web viewer.
/swipeThe Swipe app.
/viz/Visualizations.

Platform Differences

The server is built from platform-specific entry points. The API is identical except for the following routes.

RouteWindowsLinux, macOS
/api/embedding/directml/installPresentAbsent
/eventsPresent (tray event log page)Absent
/statsAbsentPresent (server-rendered statistics page)

/api/stats is available on every platform.

See Also