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.
| Level | Requirement |
|---|---|
| Public | No credential. |
| Read | A 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. |
| Admin | A 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:
| Status | Meaning |
|---|---|
400 | Malformed JSON, missing required field, invalid parameter, or a validation failure. |
401 | Missing or invalid credential. |
403 | Path outside every configured storage root; setup required; or cross-origin form post. |
404 | Unknown job, workflow, model, file, or person. |
405 | Method not supported on this path. |
409 | Move destination already belongs to another item. |
413 | Upload or image body exceeded its cap. |
500 | Database or filesystem failure. |
503 | Auto 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /auth/login | Public | Exchange credentials for a JWT. |
| POST | /auth/logout | Public | Clear the session cookie. |
| GET | /auth/status | Public | Report the current credential. |
| GET | /auth/users | Public* | List users. |
| POST | /auth/users | Public* | Create a user. |
| DELETE | /auth/users?username= | Public* | Delete a user. |
| GET | /auth/keys | Admin | List API keys. |
| POST | /auth/keys | Admin | Create an API key. |
| DELETE | /auth/keys?id= | Admin | Revoke an API key. |
| GET, POST | /auth/cli/authorize | Public | Browser approval page for the CLI login flow. |
| POST | /auth/cli/token | Public | Exchange 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /health | Public | Liveness, SSE stats, job counts by state. |
| GET | /api/stats | Read | Library statistics and metadata coverage. |
| GET | /api/config | Admin | Active configuration with secrets redacted. |
| POST | /config | Admin | Replace the configuration and apply it. |
| POST | /api/db/query | Admin | Read-only SQL. |
| POST | /api/db/load | Read | Web boot handshake. Acknowledges with {}; the web client cannot switch databases. |
| GET | /api/prompts/describe | Admin | {"prompt": "..."}, the active description prompt. |
| GET | /ollama/models | Admin | {"models": [...]} from the configured Ollama instance. |
| POST | /open | Admin | {"path": "..."}. Opens an absolute path with the host's default application. Returns {"status": "ok"}. |
| POST | /api/embedding/directml/install | Admin | Install 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}
| Field | Default | Description |
|---|---|---|
sql | required | One SELECT or WITH statement. |
args | [] | Positional values for ? placeholders. |
limit | 1000 | Row cap. Maximum 10000. |
timeout_ms | 5000 | Statement 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /create | Admin | Create a job. |
| GET | /jobs/list | Admin | All jobs as a JSON array. |
| GET | /api/jobs/for-path?path= | Admin | Jobs whose input or resources name a path: {"path", "jobs": [...]}. |
| POST | /job/{id}/cancel | Admin | Cancel. Response 200, plain text. |
| POST | /job/{id}/pause | Admin | Pause at the next item boundary. 400 if the job cannot be paused. |
| POST | /job/{id}/resume | Admin | Resume a paused job. |
| POST | /job/{id}/copy | Admin | Clone into a new pending job. Response 201: {"id", "message"}. |
| POST | /job/{id}/remove | Admin | Delete the job record. Response 200, plain text. |
| POST | /jobs/clear | Admin | Delete every job in a terminal state. Response: {"cleared_count": n, "message": "..."}. |
| GET | /tasks | Admin | Registered 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
| Field | Type | Description |
|---|---|---|
id | string | UUID. |
command | string | Task id. |
arguments | string[] | Argument tokens. |
input | string | Job input. |
original_input | string | The input as submitted, before any workflow substitution. |
host | string | Hostname of the server that ran the job. |
resources | string[] | Paths the job declared it touches. |
dependencies | string[] | Ids of jobs that must complete first. |
state | integer | 0 pending, 1 in progress, 2 completed, 3 cancelled, 4 error, 5 paused. |
created_at, claimed_at, completed_at, errored_at | RFC 3339 | Zero value when unset. |
output_files, source_files | string[] | Parallel arrays: files produced and the source each came from. |
workflow_id | string | Non-empty when the job was created by a workflow. |
progress_done, progress_total | integer | Per-item progress for per-item tasks. |
interrupt_count | integer | Times 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:
| Field | Description |
|---|---|
name | Argument name. Passed as --name value. |
label | Display label. |
type | string, bool, enum, multi-enum, or number. |
choices | Allowed values for enum types. |
default | Value used when omitted. |
required | Whether the task fails without it. |
description | Help text. |
The task list is summarized in Available Tasks.
Workflows
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /workflow | Admin | Run a DAG without saving it. |
| GET | /workflows | Admin | Saved workflows as a JSON array. |
| POST | /workflows/create | Admin | Save a workflow. Response 201 with the saved object. |
| GET | /workflows/{id} | Admin | One saved workflow. |
| PUT | /workflows/{id} | Admin | Update name and DAG. Returns the updated object. |
| DELETE | /workflows/{id} | Admin | Delete. Response 204. |
| POST | /workflows/{id}/run | Admin | Instantiate as jobs. Response 201: {"ids": [...]}. |
DAG node
| Field | Type | Description |
|---|---|---|
id | string | Node id, unique in the DAG. |
command | string | Task id. |
arguments | string[] | Argument tokens. |
input | string | Job input. |
dependencies | string[] | Node ids that must complete first. |
pos_x, pos_y | number | Editor 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /stream | Public | Server-Sent Events for job lifecycle, output, and progress. |
| GET | /api/deps/models/progress | Admin | Server-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 name | Data |
|---|---|
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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/media/query | Read | Predicate query with optional similarity ranking. |
| POST | /api/media | Read | Tag 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} ]}
| Field | Description |
|---|---|
mode | AND or OR. How predicates combine when a predicate has no join. |
predicates[].type | See the table below. |
predicates[].value | Predicate operand. Meaning depends on type. |
predicates[].exclude | Negate the predicate. |
predicates[].join | AND or OR; overrides mode for this predicate's connection to the previous one. |
predicates[].text, textWeight | Visual predicates only. Blend a text concept into the query vector. textWeight is 0 to 1; default 0.5. |
predicates[].nodes | Visual predicates only. Composite blend; see below. When present, text is ignored. |
predicates[].blendMode | Reserved for composite blends. |
| Type | Value | Semantics |
|---|---|---|
tag | Tag label | Item has the tag. |
category | Category label | Item has any tag in the category. |
path | Substring | path LIKE %value%. |
description | Substring | description LIKE %value%. |
hash | Substring | hash LIKE %value%. |
orientation | landscape, portrait, square | Width versus height. Items without dimensions never match. |
faces | ungrouped | Item has at least one face with no person and no face with a person. |
similar | Media path | Rank by cosine similarity to the item's embedding. |
visual | Free text | Rank by text-to-image similarity (SigLIP 2). |
clip | Image data URL | Rank by similarity to an uploaded image (data:image/...;base64,...). |
face | Media path or image data URL | Rank 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}]}
| Field | Description |
|---|---|
kind | image (library path), clip (data URL), or text. |
value | Path, data URL, or text. |
weight | 0 to 1. Omitted means 1. |
negative | Subtract 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
| Field | Description |
|---|---|
path | Media path. |
width, height | Pixel dimensions or null. |
elo, battles | Battle Mode rating and bout count. |
mtimeMs | Reserved; 0. |
tagLabel, weight, timeStamp | Populated 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.
Search
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/media/search | Read | {"description": "..."}. Description substring match. Optional tags and filteringMode narrow by tag. |
| GET | /api/media/similar?path=&limit= | Read | Items similar to a library item. limit default 50. Returns [{"path", "score"}]. |
| GET | /api/media/search/visual?q=&limit= | Read | Text-to-image search. limit default 50. Returns [{"path", "score"}]. |
| POST | /api/media/search/image | Read | Body is the raw image bytes (any common format, 32 MB cap). Returns ranked result items. |
| POST | /api/media/search/face | Read | Body 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/media/metadata | Read | {"path"} → {"width", "height", "size", "description", "transcript", "hash", "duration"}. duration is present for video. |
| POST | /api/media/tags | Read | {"path"} → {"tags": [{"label", "category", "weight", "timeStamp"}]}. |
| POST | /api/media/gif-metadata | Read | {"path"} → {"frameCount", "duration"} via ffprobe, or null. |
| POST | /api/media/preview | Read | {"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= | Read | Faces detected in one item. See People and Faces. |
| GET | /api/jobs/for-path?path= | Admin | Jobs touching the item. |
Mutation
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/media/description | Admin | {"path", "description"}. Empty string clears. Returns {}. |
| POST | /api/media/transcript | Admin | {"path", "transcript"}. Returns {"status": "ok"}. |
| POST | /api/media/rating | Admin | {"path", "elo"?, "views"?, "wins"?, "losses"?}. Omitted fields are unchanged; with only path the call reads. Returns {"path", "elo", "views", "wins", "losses"}. |
| POST | /api/media/battle | Admin | {"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/delete | Admin | {"path"}. Removes the item and every referencing row. The file is not deleted. |
| POST | /api/media/forget | Admin | {"path"}. Same effect as delete; named for clarity in scripts. |
| POST | /api/media/move | Admin | Re-point database references after a move on disk. |
| POST | /api/media/merge-metadata | Admin | {"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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/thumbnails | Read | {"path"} → [{"cache", "path", "exists", "size"}] for the 600 and 1200 caches. |
| POST | /api/thumbnails/regenerate | Admin | {"path", "cache", "timeStamp"}. Regenerates one thumbnail and returns its path. |
| GET | /media/thumbnail?path=&cache=&ts= | Read | Serve 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /media/file?path= | Read | Serve 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= | Read | HLS 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= | Admin | Delete cached renditions for the video. |
| GET | /media/hls/{hash}/{file} | Read | Playlists (.m3u8) and segments (.ts). |
| GET | /media/facecrop?id=&size= | Read | JPEG crop of one detected face by face id. |
| GET | /static/{file} | Public | Embedded static assets. |
| GET | /app/ | Read | The 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.
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /media/api?offset=&limit=&q= | Admin | Paged items: {"items": [...], "has_more", "total_count"}. limit default 25, maximum 100. q matches filename, description, and tags. |
| GET | /media/api?path=&single=true | Admin | One item by path. |
| GET | /media/suggest?kind=&prefix=&limit= | Read | Autocomplete. kind is filters, tag, category, path, or pathdir. limit default 25, maximum 200. Returns {"suggestions": [...]}; tag also returns {"tags": [{"label", "category"}]}. |
| POST | /media/tag | Admin | {"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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /api/taxonomy | Read | [{"label", "weight", "tagViewMode", "tags": [{"label", "category", "weight"}]}], every category with its tags. |
| GET | /api/taxonomy/categories | Read | [{"label", "weight", "description", "tagViewMode"}]. |
| GET | /api/taxonomy/tags?category=&excludeCategory= | Read | Tags 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= | Read | Distinct 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/count | Read | {"label"} → {"count": n}. |
| POST | /api/tags/preview | Read | {"label"} → the tag's preview thumbnail path or null. |
Tags
| Method | Endpoint | Access | Body | Description |
|---|---|---|---|---|
| POST | /api/tags | Admin | {"label", "categoryLabel", "weight"?} | Create. Returns {"label"}. |
| DELETE | /api/tags | Admin | {"label", "categoryLabel"} | Delete the tag and its assignments. |
| POST | /api/tags/rename | Admin | {"label", "newLabel"} | Rename. |
| POST | /api/tags/move | Admin | {"label", "categoryLabel"} | Move to another category. |
| POST | /api/tags/order | Admin | {"labels": [...]} | Set sort order by assigning ascending weights. |
| POST | /api/tags/weight | Admin | {"label", "weight"} | Set sort weight. |
| PUT | /api/tags/timestamp | Admin | {"mediaPath", "tagLabel", "oldTimestamp", "newTimestamp"} | Move a video tag timestamp. |
| DELETE | /api/tags/timestamp | Admin | {"mediaPath", "tagLabel", "timestamp"} | Remove a video tag timestamp. |
Mutation endpoints in this section return {} on success unless noted.
Categories
| Method | Endpoint | Access | Body | Description |
|---|---|---|---|---|
| POST | /api/categories | Admin | {"label"} | Create. Returns {"label"}. |
| DELETE | /api/categories | Admin | {"label"} | Delete the category, its tags, and their assignments. |
| POST | /api/categories/rename | Admin | {"label", "newLabel"} | Rename. |
| POST | /api/categories/tag-view-mode | Admin | {"label", "mode"} | Set how the viewer lists the category's tags. |
Assignments
| Method | Endpoint | Access | Body | Description |
|---|---|---|---|---|
| POST | /api/assignments | Admin | {"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/assignments | Admin | {"mediaPath" | "mediaPaths": [...], "tag": {"tag_label", "time_stamp"}} | Remove an assignment. time_stamp selects one timestamped assignment; 0 removes the untimed one. |
| POST | /api/assignments/weight | Admin | {"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.
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /api/index/status | Admin | Index and storage state. |
| GET | /api/index/models | Admin | {"models": [{"id", "name", "active", "indexed", ...}]}. |
| POST | /api/index/rebuild | Admin | Reload 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/prune | Admin | Delete vectors whose media row is gone. {"pruned_rows", "pruned_paths"}. |
| DELETE | /api/embeddings/all?confirm=true | Admin | Delete every vector. {"deleted": n}. 400 without confirm. |
| GET | /api/embeddings/projection?model=&limit= | Admin | 3D 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.
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /api/people | Read | [{"id", "name", "coverFaceId", "faceCount", "mediaCount", "lockedCount", "createdAt", "models"}]. |
| POST | /api/people | Admin | {"name"}. Create an empty person. Returns {"id", "name"}. |
| POST | /api/people/{id}/rename | Admin | {"name"}. |
| POST | /api/people/{id}/merge | Admin | {"intoId"}. Move every face into another person and delete this one. Returns {"merged", "into"}. |
| DELETE | /api/people/{id}?deleteFaces=&ban= | Admin | Delete 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}/media | Read | Media items containing the person. |
| GET | /api/people/{id}/faces | Read | {"personId", "faces": [...]}, least typical first. |
| POST | /api/people/{id}/cover | Admin | Choose the cover face. Returns {"personId", "coverFaceId"}. |
| POST | /api/people/{id}/lock | Admin | Confirm every current face. Returns {"personId", "locked": n}. |
| POST | /api/people/lock-all | Admin | Confirm every face of every named person. Returns {"people", "locked"}. |
| POST | /api/people/{id}/curate | Admin | {"keepFaceIds": [...]}. Lock the listed faces and reject the rest. Returns {"personId", "kept", "rejected"}. |
| POST | /api/media/assign-person | Admin | {"path", "personId" | "newPerson": true, "name"?, "setCover"?}. Assign every face in a media item to a person. |
| POST | /api/media/reject-person | Admin | {"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}/similar | Read | Face hits ranked by identity similarity to one face. |
| POST | /api/faces/{id}/assign | Admin | {"personId" | "newPerson": true, "name"?}. Confirmed assignment. Returns {"faceId", "personId", "created", "name"}. |
| POST | /api/faces/{id}/unassign | Admin | Detach the face from its person. Returns {"faceId", "unassigned": true}. |
| POST | /api/faces/{id}/reject | Admin | {"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/stats | Read | {"faces": [{"model", "count", "bytes"}], "total_faces", "total_bytes", "scans", "people": {"total", "named", "unnamed"}}. |
| GET, POST | /api/faces/tuning | Admin | {"thresholdOffset", "minCluster", "minQuality"}. POST merges the supplied fields and returns the full object. |
| DELETE | /api/faces/all?confirm=true | Admin | Delete 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| POST | /api/fs/list | Read | {"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/scan | Read | {"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/upload | Admin | Multipart upload. |
| GET | /api/export | Admin | Stream 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/import | Admin | Multipart: 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 field | Description |
|---|---|
files | One or more file parts. Required. |
destination | Directory inside a storage root. Default: uploads/ under the default root. |
autoIngest | false 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /api/deps/status | Admin | Every bundled binary, optional tool, and downloadable model with its state. |
| POST | /api/deps/models/{id}/download | Admin | Start a download. 202 with a progress object; 200 {"state": "installed"} when already installed; 404 for an unknown id. |
| POST | /api/deps/models/{id}/cancel | Admin | Cancel an active download. 202; 404 when none is active. |
| POST | /api/deps/models/{id}/verify | Admin | Checksum installed files: {"id", "files": {"<rel_path>": "ok" | "<error>"}}. |
| DELETE | /api/deps/models/{id} | Admin | Remove a model. 204. |
| GET | /api/deps/models/progress | Admin | Server-Sent Events; each event's data is a progress object. |
| GET | /api/onboarding/state | Admin | {"shown", "dismissed_at"} for the first-run tour. |
| POST | /api/onboarding/dismiss, /api/onboarding/reset | Admin | Dismiss 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /swipe | Read | The Swipe progressive web app. |
| GET | /swipe/manifest.json | Public | PWA manifest. |
| GET | /swipe/api | Read | Feed items: {"items": [...], "has_more"}. |
| Parameter | Description |
|---|---|
mode | Omitted: 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. |
q | Search text (shuffle filter, or the query for mode=text). |
anchor | Media path. Required for similar and face. Excluded from its own results. |
seed | Integer shuffle seed. Same seed, same order. |
session | Client session id for mode=feed. Default default. |
orientation | landscape, portrait, or square. |
offset, limit | Paging. 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.
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /api/settings | Read | The settings object. |
| PUT | /api/settings | Read (write requires credential) | Merge the body into settings. |
| GET | /api/session | Read | The whole session object. |
| GET | /api/session/{key} | Read | One value. |
| PUT | /api/session | Read (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/session | Read (write requires credential) | Clear the session. |
| POST | /api/session/keys | Admin | {"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.
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /api/scheduler | Admin | Status object. |
| POST | /api/scheduler/mode | Admin | {"mode": "off" | "auto"}. Returns the status object. |
| POST | /api/scheduler/run | Admin | Force 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
| Method | Endpoint | Access | Description |
|---|---|---|---|
| GET | /viz/ | Admin | Index of visualizations. |
| GET | /viz/embeddings | Admin | 3D embedding point cloud. |
| GET | /viz/treemap | Admin | Library treemap by folder. |
| GET | /api/treemap?metric=&depth=&limit=&path= | Admin | Tree data rooted at path. limit default 100, maximum 2000 children per node. |
| GET | /api/treemap/metrics | Admin | {"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.
| Method | Endpoint | Description |
|---|---|---|
| GET | /setup/api/state | {"setupComplete", "authed", "hasRealUsers", "os", "homeDir", "dataDir", "defaultDBPath", "activeDBPath", "roots", "deps", "modelGroups"}. |
| POST | /setup/api/browse | List a directory on the server host for the folder picker. |
| POST | /setup/api/mkdir | Create a directory. |
| POST | /setup/api/database | Choose or create the SQLite database file. |
| POST | /setup/api/storage/test-s3 | Test S3 credentials and bucket access. |
| POST | /setup/api/storage | Save the storage roots. |
| POST | /setup/api/complete | Mark setup complete. |
HTML Pages
Server-rendered administration pages. All require an admin credential
except /login.
| Path | Description |
|---|---|
/ | Home: library stats, metadata coverage, ingestion, job queue. |
/jobs | Job list. |
/job/{id} | Job detail with live output. |
/login | Sign-in form. ?redirect= returns to a page; ?setup=true forces account creation. |
/config | Configuration editor (GET renders; POST saves, see System). |
/editor | Job composer. |
/media | Legacy gallery. |
/setup | First-run wizard. |
/app/ | The full web viewer. |
/swipe | The Swipe app. |
/viz/ | Visualizations. |
Platform Differences
The server is built from platform-specific entry points. The API is identical except for the following routes.
| Route | Windows | Linux, macOS |
|---|---|---|
/api/embedding/directml/install | Present | Absent |
/events | Present (tray event log page) | Absent |
/stats | Absent | Present (server-rendered statistics page) |
/api/stats is available on every platform.