API¶
The API lives under /api/v1. Interactive docs with every request and response schema are at /docs (Swagger UI) and /redoc on the backend; the schema at /openapi.json generates the frontend's typed client (one class per tag, e.g. Recordings.getRecording).
Authenticate with Authorization: Bearer <token>: an API token (la_…, made in the app or with POST /tokens), the access token of an app someone gave access to through OAuth (lo_…), or a session's access token. People sign in with a passkey (POST /auth/passkey answers a one-time ticket, which POST /auth/ticket swaps for a token pair); password sign-in (POST /auth/login) is off unless an admin turns on auth.passwords. See Authentication. Errors are JSON {"detail": …}; validation errors answer 422. Namespaces you can't read answer 404.
Outside /api/v1: IIIF resources under /iiif/… (IIIF), the embeddable player at /embed/<id> (and at a share link's short address, /s/<code>) and stored reports at /reports/<namespace>/….
auth¶
GET /api/v1/auth/status
POST /api/v1/auth/setup
POST /api/v1/auth/login
POST /api/v1/auth/refresh
POST /api/v1/auth/logout
GET /api/v1/auth/me
PATCH /api/v1/auth/me
POST /api/v1/auth/me/tour
POST /api/v1/auth/password
POST /api/v1/auth/password/forgot
POST /api/v1/auth/password/reset
POST /api/v1/auth/passkey/setup/options
POST /api/v1/auth/passkey/setup
POST /api/v1/auth/setup/no-passkey
POST /api/v1/auth/passkey/options
POST /api/v1/auth/passkey
POST /api/v1/auth/ticket
GET /api/v1/auth/passkeys
POST /api/v1/auth/passkeys/options
POST /api/v1/auth/passkeys
PATCH /api/v1/auth/passkeys/{pid}
DELETE /api/v1/auth/passkeys/{pid}
POST /api/v1/auth/signin-link/info
POST /api/v1/auth/signin-link/options
POST /api/v1/auth/signin-link
POST /api/v1/auth/signin-link/use
POST /api/v1/auth/signin-link/lost
GET /api/v1/auth/external
POST /api/v1/auth/external/{key}/start
POST /api/v1/auth/external/{key}/connect
GET /api/v1/auth/identities
DELETE /api/v1/auth/identities/{iid}
GET /api/v1/auth/providers
POST /api/v1/auth/providers
PATCH /api/v1/auth/providers/{key}
DELETE /api/v1/auth/providers/{key}
GET /auth/me gives your account, your roles (namespace → role) and partial: the namespaces you have no role in
but see some collections of (Access), and toured_at, when you finished or skipped the
welcome tour (POST /auth/me/tour sets it; repeating it keeps the first time).
Passkey sign-in, sign-in links (emailed by an admin, or by signin-link/lost for a lost passkey) and outside accounts
(/auth/external, configured by admins under /auth/providers) all answer a one-time ticket; POST /auth/ticket
swaps it for a token pair. /auth/passkeys manages your own passkeys and /auth/identities your linked outside
accounts. See Authentication.
tokens¶
GET /api/v1/tokens
GET /api/v1/tokens/limits
POST /api/v1/tokens
PATCH /api/v1/tokens/{token_id}
DELETE /api/v1/tokens/{token_id}
GET /api/v1/admin/tokens
DELETE /api/v1/admin/tokens/{token_id}
POST /tokens {name, scope, days?, namespace?} makes an API key (while signed in); with namespace (one you can read)
it opens that namespace's own MCP server and chat completions alone, and the list shows it as namespace
(API tokens). days defaults to tokens.default_days; more
than tokens.max_days, or 0 (never expires) when tokens.never_expire is off, is a 400. GET /tokens/limits says
{default_days, max_days, never_expire}. PATCH /tokens/{token_id} {name} renames one of your keys (audited as
token.rename). /admin/tokens lists everyone's keys with their owner's email (admins), and
DELETE /admin/tokens/{token_id} revokes any of them; revoking is audited as token.revoke.
oauth¶
GET /.well-known/oauth-authorization-server
GET /.well-known/oauth-protected-resource
POST /api/v1/oauth/register
GET /api/v1/oauth/authorize
POST /api/v1/oauth/authorize
POST /api/v1/oauth/token
POST /api/v1/oauth/revoke
GET /api/v1/oauth/grants
DELETE /api/v1/oauth/grants/{grant_id}
Lens is an OAuth 2.1 authorization server for API and MCP clients (Authentication). The MCP
server itself is at /mcp, outside /api/v1 (MCP server).
POST /oauth/register {redirect_uris, client_name?, client_uri?, token_endpoint_auth_method?}(RFC 7591, no sign-in, 201) registers an app:client_id(lc_…), and aclient_secret(ls_…, shown once) when it asked forclient_secret_postorclient_secret_basicinstead ofnone. Redirect addresses are https,http://localhostor127.0.0.1(any port), or the app's own scheme; other metadata is ignored. Errors are{error, error_description}; more than 8 apps from one address in 15 minutes is a 429.GET /oauth/authorize?client_id=&redirect_uri=&code_challenge=&code_challenge_method=S256&scope=(signed in, as a person) answers what the consent page shows:{client: {id, name, uri}, redirect_uri, scope, granted},scopebeingreadorread writeandgrantedwhat this app was given before. A request that can't be answered (an unknown app, an address it didn't register, no S256 challenge) is a 400 with the reason.POST /oauth/authorize {client_id, redirect_uri, code_challenge, code_challenge_method, scope?, state?, resource?, approve, grant?}answers it:{redirect_to}, the app's address withcodeandstate, or witherror=access_denied.grant: "read"gives less than was asked for.POST /oauth/token(form-encoded) withgrant_type=authorization_code,code,redirect_uri,code_verifierandclient_id(andclient_secret, or HTTP Basic, for apps that have one), or withgrant_type=refresh_tokenandrefresh_token:{access_token, token_type: "Bearer", expires_in, refresh_token, scope}. Errors are{error, error_description}(invalid_grant,invalid_clientwith 401,unsupported_grant_type).POST /oauth/revoke(form-encoded:token,client_id) ends the access the token belongs to; it answers{ok: true}whether or not the token was known.GET /oauth/grantslists the apps you gave access to (id,client,name,uri,scope,created_at,last_used_at,expires_at);DELETE /oauth/grants/{grant_id}takes one's access away.
GET /auth/me says via: "oauth" for an app's token, with scope read or write.
users¶
GET /api/v1/users
POST /api/v1/users
PATCH /api/v1/users/{uid}
DELETE /api/v1/users/{uid} remove for good (authentication.md#removing-someone)
POST /api/v1/users/{uid}/signin-link ?email=true also emails it
GET /api/v1/namespaces/{name}/members
PUT /api/v1/namespaces/{name}/members {email | account, role, expires_at}: built-in or custom; null removes
GET /api/v1/roles the permissions and every role (access.md#roles-and-permissions)
POST /api/v1/roles admins: {name, label, description, permissions}
PATCH /api/v1/roles/{name} admins: a custom role's label, description, permissions
DELETE /api/v1/roles/{name} admins: a custom role nobody holds
admin¶
GET /api/v1/settings
PUT /api/v1/settings/{section}
POST /api/v1/settings/llm/test
POST /api/v1/settings/embeddings/test
POST /api/v1/settings/mail/test
POST /api/v1/settings/files/test
POST /api/v1/settings/speech/test
GET /api/v1/settings/decisions/status
POST /api/v1/settings/decisions/test
GET /api/v1/settings/telemetry/status
POST /api/v1/settings/telemetry/test
GET /api/v1/settings/bridge
POST /api/v1/settings/bridge/test
GET /api/v1/settings/tunnel/status
GET /api/v1/settings/local-llm/status
DELETE /api/v1/settings/local-llm/models
GET /api/v1/audit
GET /api/v1/admin/health
POST /api/v1/admin/reindex
GET /api/v1/admin/semantic
POST /api/v1/admin/semantic/index
POST /settings/embeddings/test embeds one sentence with the configured embedding model (dimension, ms, or the
error). GET /admin/semantic says whether search by meaning is set up, with which model, and how many assets
and passages are indexed with it; POST /admin/semantic/index?limit=500 queues the embed step for assets not yet
indexed with it (remaining when more are waiting). The other test endpoints try the saved settings of their
section (mail, file store, speech provider, decision model, telemetry, bridge) and answer what happened or the
error; the status endpoints say how that part is doing. All are for admins.
activity¶
GET /api/v1/activity
GET /api/v1/activity/totals
GET /api/v1/activity/costs
GET /api/v1/activity/top
GET /activity?resource=<table>:<id> is a resource's history, newest first (Activity and costs).
/activity/totals is what one resource (or, for admins, everything) cost this period (day, week, month,
all). GET /activity/costs?resource=…&resource=… (up to 500) answers each resource's cost at once for lists,
leaving out the ones you can't see; estimate marks a figure as a floor. /activity/top lists the costliest
resources (admins).
automations¶
GET /api/v1/automations
POST /api/v1/automations
GET /api/v1/automations/actions
POST /api/v1/automations/draft
GET /api/v1/automations/starters
GET /api/v1/automations/{id}
PATCH /api/v1/automations/{id}
DELETE /api/v1/automations/{id}
POST /api/v1/automations/{id}/restore
POST /api/v1/automations/{id}/enable
POST /api/v1/automations/{id}/policy
POST /api/v1/automations/{id}/test
POST /api/v1/automations/test
POST /api/v1/automations/{id}/run
GET /api/v1/automations/{id}/runs
GET /api/v1/automations/{id}/upcoming
GET /api/v1/automations/runs
GET /api/v1/automations/runs/{run}
POST /api/v1/automations/runs/{run}/decide
Automations act for the person who made them in namespaces they can edit (Saving
automations). Admins see and change all of them; anyone else the ones that work
only in namespaces they can edit (?removed=true lists deleted ones, to restore). A save answers {id, hooks}.
/policy lets an action node run without asking (auto) only from a signed-in session in the web app.
/actions lists what action nodes can do. /draft {"text": ...} asks the model to turn a sentence into a draft to
try and save, saying what didn't fit (dropped) and what it couldn't express (unmapped) (Describe it in
words). The list adds each one's actions waiting (waiting), cost this month
(cost_month), its triggers in words (reads), its runs today (runs_today) and when it runs next (next_at). /test tries a saved automation, and POST /automations/test {"draft": ...} an unsaved one, on an
activity (activity) or a kind's sample (kind) without keeping or sending anything; /run runs it now (on
activity, else the newest one its triggers listen for). /runs pages its runs (before, limit; next for the
page after), /runs?activity=<id> lists the runs an activity started, /runs/{run} gives one with its trace, log and
intents, and /runs/{run}/decide {"run": true} runs a run held over budget, or skips it (Running
automations).
activities¶
GET /api/v1/activities
GET /api/v1/activities/kinds
GET /api/v1/activities/{activity_id}
POST /api/v1/activities/mark
POST /api/v1/activities/{activity_id}/mark
POST /api/v1/activities/{activity_id}/react
GET /api/v1/activities/{activity_id}/comments
POST /api/v1/activities/{activity_id}/comments
PATCH /api/v1/activities/{activity_id}/comments/{cid}
DELETE /api/v1/activities/{activity_id}/comments/{cid}
GET /activities is what happened in Lens, newest first, from every source: Activity Streams 2.0 activities with a
Lens kind (the shape is in Automations). Filters, each optional: kinds (repeat it,
or separate with commas; the kinds under each count too), actor (an actor's id, like lens:account/1),
namespace (by name), tag (an entity's id or name), since (published after) and until (at or before),
q (words in the summary) and where (a match tree on the activity, as JSON).
limit is 1 to 200 (50). The answer is {items, next, namespaces}: give next as before for the page after (it is
null at the end); namespaces names the namespaces in the page by id. Noisy kinds are left out unless named. People
see the activities of the namespaces they can read, their own (lens.audience owner), and admins also those for
admins alone. /activities/kinds lists the kinds of activity with their schemas and samples; /activities/{id}
reads one activity, like create-recording-7.
Each person marks activities reviewed, important or hidden for themselves alone (Timeline):
POST /activities/{id}/mark with any of the three (true or false; one left out stays) answers the marks on it now,
and POST /activities/mark with ids (up to 500) answers {marks, unknown}. GET /activities has your marks on
the page in marks and filters by them with reviewed, important and hidden (true or false).
Reactions and comments are shared by everyone who sees the activity
(Timeline). POST /activities/{id}/react with {"emoji": "👍"} (one of
👍 ❤️ 🎉 👀 😂 😮 😢 🙏; "on": false takes yours back) answers {reactions: [{emoji, count, mine}]} and logs a
reaction.added activity when it is new. /activities/{id}/comments is the activity's thread, shaped as
comments on a resource: GET lists them threaded, POST with text (and parent for a reply) writes
one, PATCH changes its text (its writer) or resolves the thread (resolved: its writer or an editor of the
namespace), DELETE deletes it (its writer or an owner). On an asset's arrival (create-recording-<id>) they are the
asset's own comments. GET /activities carries everyone's reactions on the page in reactions and each item's
comment count in comments, by id. Writing needs a session or a write-scope key; a read-only key reads them.
circles¶
GET /api/v1/circles
POST /api/v1/circles
GET /api/v1/circles/people
GET /api/v1/circles/shares
POST /api/v1/circles/shares
POST /api/v1/circles/when-free
POST /api/v1/circles/ask
GET /api/v1/circles/elsewhere
PUT /api/v1/circles/elsewhere
GET /api/v1/circles/shares/{share_id}/elsewhere
DELETE /api/v1/circles/shares/{share_id}
GET /api/v1/circles/{circle_id}
PATCH /api/v1/circles/{circle_id}
DELETE /api/v1/circles/{circle_id}
Your circles of trust and what you share with them; yours alone. POST /circles takes a name and
members (account ids or emails of people you can add, from /circles/people); PATCH renames, sets members, or
adds and removes people. POST /circles/shares shares an activity (its id) or a recording you can see with
circles and people in them, with a note; with_item also gives those who can't open the resource permission on
it (needs publish there). Each person gets share.received in their timeline and you get share.sent.
GET /circles/shares?direction=received|sent lists them newest first; DELETE /circles/shares/{id} takes one back,
with the permissions it gave. A circle's lets says what its people may ask your agent: free_busy lets them see when
you're free. POST /circles/when-free takes people, a window (start, end, at most 31 days, in tz), minutes,
day_start and day_end, and answers each person's busy times (only when, never what) or why not, and the free
stretches when you and all of them are free; each person asked gets agent.asked in their timeline. With lets
holding questions and about naming namespaces of yours, POST /circles/ask (person, question) answers from
those namespaces only, with its sources; the person asked sees the question and the answer. Once an admin turns on
circles with other servers, PUT /circles/elsewhere (findable) gives you a handle, circles take
people elsewhere by handle (remote, add_remote, remove_remote), and GET /circles/shares/{id}/elsewhere says
how a share is getting on with them.
automations¶
POST /api/v1/automations/hooks/{token}
An automation's webhook (Webhook trigger): calling the address of a trigger
{"on": "webhook"} sets that automation off. No session or API token: the token in the address (lah_..., shown
once when the automation is saved) is the credential. Send JSON, a form (text fields) or text, up to 64 KB; the answer
(202) is {"id"}, the webhook.received activity the call made, before the automation runs. Sign the call the
Standard Webhooks way (webhook-id, webhook-timestamp, webhook-signature, with the secret shown with the
address) and the signature is checked; a webhook-id or Idempotency-Key makes a retried call count once. 401 for a
token that isn't a webhook's or a signature that doesn't match, 403 while the automation is switched off, 413 over 64
KB, 415 for other bodies, 429 past 60 calls a minute.
curl -H "Content-Type: application/json" -d '{"event": "doorbell"}' https://lens.example/api/v1/automations/hooks/lah_...
GET /api/v1/automations/{id}/vars
PUT /api/v1/automations/{id}/vars
GET /api/v1/namespaces/{name}/automation-vars
PUT /api/v1/namespaces/{name}/automation-vars
The values automations keep between runs (Saved variables): an automation's own,
or a namespace's, which every automation working there reads. GET answers {"vars": [{name, value, untrusted,
updated_at, updated_by}]} by name; untrusted says a run set it from text from outside Lens. PUT {"vars": {name:
value}} sets several at once (null clears one) and answers the same list. An automation's are for admins and
whoever may change it (it works only in namespaces they edit); a namespace's are read by its readers and changed by
its editors. 404 for an automation you can't see, 400 for a bad name (lowercase letters, digits and _, up to 40),
a value over 16 KB, or past 100 variables.
curl -X PUT -H "Authorization: Bearer $LENS_TOKEN" -H "Content-Type: application/json" \
-d '{"vars": {"alerts": 0, "last_alert": null}}' https://lens.example/api/v1/automations/7/vars
POST /api/v1/automations/variables
POST /api/v1/automations/preview
The variable picker and a template's preview for an automation being written
(The variable picker and preview). Both take draft, the automation
as written so far ({triggers, join?, workflow?, spaces?, id?, name?}, workflow an id or a graph). variables
({draft, node?}) answers {"variables": [{path, type, label, example, group, untrusted}]}. preview ({template,
draft, activity?, trigger?, node?, prompt?}) answers {text, value, prompt, rules, system, error, line, activity};
a template that fails is a 200 with error and line. 400 for a draft that doesn't check, 404 for an activity, a
namespace or an automation you can't see.
curl -H "Authorization: Bearer $LENS_TOKEN" -H "Content-Type: application/json" https://lens.example/api/v1/automations/preview \
-d '{"template": "{{ events.mail.object.subject }}", "draft": {"triggers": [{"as": "mail", "on": "event", "kinds": ["email.received"]}]}}'
vaults¶
GET /api/v1/namespaces/{name}/vault
POST /api/v1/namespaces/{name}/vault/options
POST /api/v1/namespaces/{name}/vault
POST /api/v1/namespaces/{name}/vault/unlock
POST /api/v1/namespaces/{name}/vault/lock
POST /api/v1/namespaces/{name}/vault/passkeys
DELETE /api/v1/namespaces/{name}/vault/passkeys/{pid}
DELETE /api/v1/namespaces/{name}/vault
A vault is a namespace whose files only its owners' passkeys open. options starts the passkey prompt for sealing,
unlocking or adding a passkey; POST …/vault seals the namespace, unlock opens it for encryption.vault_minutes,
lock closes it now, and DELETE …/vault (while open) makes it an ordinary namespace again. Each is audited as
vault.<action>. See Encryption at rest.
content types and custom nodes¶
GET /api/v1/content-types
POST /api/v1/content-types
PATCH /api/v1/content-types/{key}
DELETE /api/v1/content-types/{key}
GET /api/v1/recordings/{rid}/content-type
PUT /api/v1/recordings/{rid}/content-type
GET /api/v1/custom-nodes
POST /api/v1/custom-nodes
GET /api/v1/custom-nodes/{nid}
POST /api/v1/custom-nodes/{nid}/versions
PATCH /api/v1/custom-nodes/{nid}
DELETE /api/v1/custom-nodes/{nid}
Content types (admins change them; everyone reads them) say what kind of thing an asset is, and an asset's
content-type sets it. Custom nodes are versioned steps people write for workflows (?scope= filters the list,
?version= reads an older one).
routines¶
GET /api/v1/routines
GET /api/v1/routines/schedule
POST /api/v1/routines
GET /api/v1/routines/{rid}
PATCH /api/v1/routines/{rid}
DELETE /api/v1/routines/{rid}
POST /api/v1/routines/{rid}/run
GET /api/v1/routines/{rid}/runs
POST /api/v1/routines/runs/{run_id}/decide
GET /api/v1/routine-runs/{run_id}
POST /api/v1/routine-runs/{run_id}/undo
GET /api/v1/graph-changes
POST /api/v1/graph-changes/{cid}/accept
POST /api/v1/graph-changes/{cid}/dismiss
POST /api/v1/graph-changes/{cid}/undo
Routines are for admins. GET /routines/schedule?schedule=&timezone= previews when a schedule would next run; run
starts one now and runs lists its last runs. A run held over a budget is run or skipped with decide, and undo
takes back every graph change a run made. GET /graph-changes lists the changes to the entities of namespaces you can
read, newest first; editors accept, dismiss or undo one (Graph history).
schedules¶
GET /api/v1/schedules
GET /api/v1/schedules/{id}
PATCH /api/v1/schedules/{id}
POST /api/v1/schedules/{id}/run
Everything that runs on a clock, in one list (Schedules): routines and the scheduled backup for
admins, and the schedule and at triggers of the automations you manage. An id is routine:3,
automation:4:morning or backups; PATCH takes cron, at, timezone and enabled, and run runs it now.
namespaces¶
GET /api/v1/namespaces
POST /api/v1/namespaces
PATCH /api/v1/namespaces/{name}
GET /api/v1/namespaces/{name}/wordcloud.svg
GET /api/v1/namespaces/{name}/stats
GET /api/v1/namespaces/{name}/ip-groups
POST /api/v1/namespaces/{name}/ip-groups
PATCH /api/v1/namespaces/{name}/ip-groups/{gid}
DELETE /api/v1/namespaces/{name}/ip-groups/{gid}
GET /api/v1/namespaces/{name}/notifications
POST /api/v1/namespaces/{name}/notifications
PATCH /api/v1/namespaces/{name}/notifications/{tid}
DELETE /api/v1/namespaces/{name}/notifications/{tid}
POST /api/v1/namespaces/{name}/notifications/{tid}/test
POST /api/v1/namespaces/{name}/notifications/{tid}/secret
GET /api/v1/namespaces/{name}/notifications/{tid}/deliveries
GET /api/v1/namespaces/{name}/nsfw
PUT /api/v1/namespaces/{name}/nsfw
POST /api/v1/namespaces/{name}/nsfw/classify
GET /api/v1/namespaces/{name}/collections
POST /api/v1/namespaces/{name}/collections
GET /api/v1/namespaces/{name}/collections/{cid}
PATCH /api/v1/namespaces/{name}/collections/{cid}
DELETE /api/v1/namespaces/{name}/collections/{cid}
GET /api/v1/namespaces/{name}/collections/{cid}/members
PUT /api/v1/namespaces/{name}/collections/{cid}/members
GET /api/v1/namespaces/{name}/integrations
PATCH /api/v1/namespaces/{name}/integrations
GET /api/v1/namespaces/{name}/models
POST /api/v1/namespaces/{name}/chat/completions
GET /namespaces lists the namespaces you have a role in, with their counts and your role, and those you see only
some collections of: partial: true, no role, counted over those collections, without the namespace-wide word
cloud and people.
/namespaces/{name}/stats?from=&to=&top= gives the Reports overview its numbers (viewers of the namespace): the
assets made from from to to (days, YYYY-MM-DD, both included; leave either open), how many and how long
(recordings, ms), how many people are heard in them and the top ones by talk time in them (top_speakers,
default 5, at most 50, each with talk_ms and recordings), the days of the first and last, and months: every
calendar month from from (else the first asset) to to (else the later of today and the last asset),
empty ones included, at most the latest 240. Without a range it counts every asset; undated says how many
have no date, and those are never in a month or a range. Months and days are the asset dates as stored (the
time where it was recorded). 400 when to comes before from.
/namespaces/{name}/ip-groups lists a namespace's IP groups (Access, owners), with address:
your address as the server sees it (null when it can't tell; see server.trusted_proxies), and on each group here
(your address is in it) and chosen (how many assets it opens when it doesn't open everything). POST with
{"name", "ranges", "everything"} adds one: ranges are addresses or CIDR ranges, at most 100, none wider than /8
(IPv4) or /16 (IPv6); names are unique in the namespace. PATCH changes any of them and DELETE removes the group.
All three answer with the list and are audited as namespace.ip_group.create, .update and .delete.
/namespaces/{name}/notifications lists a namespace's notification targets (Notifications,
owners), the events they can get, and whether notifications are enabled for the server. POST with
{"name", "kind", "url", "events"} adds one: kind is webhook, matterbridge, slack or discord, and a
Matterbridge target also takes gateway (required), username and token. A webhook comes back with its signing
secret, this once; POST …/{tid}/secret makes a new one. A target's url comes back as its scheme and host only.
PATCH changes the name, events, enabled, the address (in full) and Matterbridge's gateway, name and token (""
removes it). POST …/{tid}/test sends a test message now and answers {ok, code, error}; GET …/{tid}/deliveries
lists the latest 50 messages with their status (pending, sending, sent, failed, dropped), attempts,
answer code and error. Changes are audited as namespace.notification.create, .update, .delete and .secret.
/namespaces/{name}/nsfw says how NSFW classification works in a namespace (viewers): whether it's
enabled, the threshold and display that apply, what the namespace chose itself (own, null where it follows
the archive) and the archive's settings (archive), and how many resources are classified, flagged and
unclassified. PUT with {enabled?, threshold?, display?, classify?} changes the namespace's own choices (owners;
null follows the archive again, a new threshold flags or unflags what was classified at once), and classify: true
also queues the nsfw step for its videos, documents and images not classified yet, answering how many runs were
queued. Audited as namespace.nsfw. POST …/nsfw/classify queues them on its own (editors) and answers the jobs.
Both are a 409 where classification is off in the namespace.
/namespaces/{name}/integrations says whether the namespace's own MCP server
(mcp, at mcp_url) and its chat completions (chat, at chat_url) are on, the
read tools the model may use there (tools) and the ones it may be offered (tool_choices), for anyone who can read
the namespace. PATCH with any of {mcp, chat, tools} changes them (owners); what isn't sent stays. A tool that
isn't one of tool_choices is a 400. Audited as namespace.integrations.
Chat completions for any app¶
A namespace can answer OpenAI's chat completions API, so any app that speaks it (a chat app, an automation tool, a library's OpenAI client) gets answers from the configured model, grounded in that namespace and citing it. Give the app:
- Base URL:
https://lens.example.org/api/v1/namespaces/<namespace>(the app adds/chat/completions); - API key: a Lens API token (
la_…; read-only is enough), best one made for this namespace alone (Connect apps links to it), or an app's OAuth token; - Model:
lens, the configured model. One of the models people may pick in Lens's chat works too (GET …/modelslists them); any other name answers with the configured model.
curl https://lens.example.org/api/v1/namespaces/family/chat/completions \
-H "Authorization: Bearer la_…" -H "Content-Type: application/json" \
-d '{"model": "lens", "messages": [{"role": "user", "content": "When is the dentist?"}]}'
Each request is answered like a question in Lens's chat, from that namespace alone, with what the token's person can read there:
- the last message is the question (a
usermessage), theuserandassistantmessages before it the conversation, andsystem(ordeveloper) messages the app's own instructions, which the model reads after Lens's rules. Pictures and files in a message are left out; - the namespace's excerpts that best answer it are found and the model answers from them, citing each claim as
[n]. With no model set up, the best excerpts come back on their own; - when the namespace's owners picked tools, the model may first look things up with them, in the namespace. They are
the assistant's tools that only read (
tool_choices). A model that can't call tools answers from the excerpts and says so inlens.notice; - when the namespace's own assistant is on, its name and instructions answer.
What it cites comes back three ways, so any app can show it: [n] in the text with a Sources list of links after
it (left out when the request has a response_format), citations (the link for [n] is the n-th), and
lens.sources (n, recording_id, title, time, speaker, url, cited). lens.steps lists the tools the
model used, and lens.record what produced the answer, as a chat answer's record
(What each answer records) without tokens and cost. With "stream": true the answer comes as OpenAI's chat.completion.chunk events, ending with
data: [DONE]; the last chunk before it carries finish_reason: "stop", citations and lens. While the tools run,
the stream sends SSE comments, so nothing in between closes it.
It is off until an owner of the namespace turns it on: Admin → Namespaces → the namespace → Connect apps,
which also copies the base URL and picks the tools, or PATCH /namespaces/{name}/integrations with {"chat": true},
or an agent's set_namespace_integrations. Errors come in OpenAI's shape ({"error": {"message", "type", "code"}}):
401 invalid_api_key without a token, 422 for a body that isn't a request (FastAPI's shape), 404 not_found for a
namespace the token's person can't read (or that doesn't exist), 403 endpoint_off while it's off, 400
invalid_messages with nothing to answer, 429 insufficient_quota when the namespace's budget or
Lens's overall one is used up, 502 upstream_error when the model server fails.
It only reads. Model calls go through the activity ledger and count for the namespace (space:<id>), so its budget
covers them. Not yet: the app's own tools (they are ignored), max_tokens, enforcing a response_format, token
counts in usage, streaming the tools' answer piece by piece (it comes in one chunk), and what the namespace's
assistant remembers.
Collections of a namespace¶
Every asset lives in exactly one collection of its namespace, and collections nest (at most 8 deep, 5000 per namespace). Each namespace has a default collection, "General" when it's made, where assets go when nobody says where: imports, uploads, scans, watched folders and assets moved in from another namespace. Existing assets were put in their namespace's General when collections came in. (Saved collections, under collections, are something else: lists of assets from anywhere.)
GET /namespaces/{name}/collections lists them depth first, by name (viewers): each with its parent (null at the
top), depth, path (names from the top down), how many assets it holds (recordings) and holds with the
collections inside it (total), how many collections are directly inside it (children), and default.
POST {name, parent?, description?} makes one (editors); a name is unique among the collections next to it, ignoring
case and repeated spaces. PATCH renames it, describes it, moves it inside another collection of the namespace
(parent; null: to the top; not inside itself), with the assets and collections in it, or makes it the default
(default: true; false on the default is a 400: make another one the default instead). DELETE removes an empty
collection: 409 while it holds assets or collections, or is the default, so deleting one never takes an
asset with it. Audited as collection.create, collection.update (with what changed) and collection.delete.
In IIIF each collection is a Collection inside its namespace's (IIIF).
People can be given a role on a collection, which holds for the ones inside it too (Access).
Each collection in the list says what you may do with it: your role on it (viewer, editor or admin; an owner
of the namespace is an admin of every collection), can_change (rename, describe, move or delete it and make
collections inside it: editors of the namespace, and admins of the collection) and can_grant (give roles on it:
owners of the namespace, and admins of the collection). Someone who sees only some collections of a namespace gets
those, starting from the ones they were given (their path and depth start there). An admin of a collection can't
make collections at the top of the namespace, move one there or into a collection they aren't an admin of, or change
the default. GET …/{cid}/members lists who was given a role on the collection and then who has one through a
collection it's inside (inherited_from); PUT …/{cid}/members {email | account, role, expires_at} gives, changes or
(role: null) takes away a role, answers with the members, and is audited as collection.member. People with a role
in the namespace aren't listed: theirs holds everywhere in it.
Grants that end. A role in a namespace, a role on a collection and permission on an asset take an optional
expires_at (a date and time, UTC unless it says otherwise) when they're given or changed: after it, the grant opens
nothing anywhere (Access). null means no end; leaving it out keeps the end the grant
had, unless that has passed (given again after it ended, a grant starts over without one). A date that has passed
answers 400. Lists (…/members, …/permissions) give each grant's expires_at and expired.
resources¶
A resource is what the archive holds: a recording (audio, video, or text without media), a document (a PDF) or
an image (Documents and images). The API calls them resources: /api/v1/resources/… is the canonical path for everything below, and the schema and the generated
client use it (the Resources class). /api/v1/recordings/…, their address before, keeps working for existing
clients and reaches the same routes; links the server writes (signed media links, for one) may still use it. Fields
keep their names (recording, RecordingSummary). The web app's pages moved too: /resources/<id>, with
/recordings/<id> redirecting there.
GET /api/v1/resources
GET /api/v1/resources/tags
GET /api/v1/resources/origins
GET /api/v1/resources/languages
GET /api/v1/resources/objects
GET /api/v1/resources/nsfw
GET /api/v1/resources/map-tiles
POST /api/v1/resources/tags
POST /api/v1/resources/collection
GET /api/v1/resources/{rid}
PATCH /api/v1/resources/{rid}
DELETE /api/v1/resources/{rid}
POST /api/v1/resources/{rid}/move
GET /api/v1/resources/{rid}/access
PUT /api/v1/resources/{rid}/access
GET /api/v1/resources/{rid}/permissions
POST /api/v1/resources/{rid}/permissions
PATCH /api/v1/resources/{rid}/permissions/{account}
DELETE /api/v1/resources/{rid}/permissions/{account}
GET /api/v1/resources/{rid}/requests
POST /api/v1/resources/{rid}/requests/{account}/approve
POST /api/v1/resources/{rid}/requests/{account}/decline
GET /api/v1/resources/{rid}/ip-groups
PUT /api/v1/resources/{rid}/ip-groups/{gid}
DELETE /api/v1/resources/{rid}/ip-groups/{gid}
GET /api/v1/resources/{rid}/player
PUT /api/v1/resources/{rid}/nsfw
POST /api/v1/resources/{rid}/enhance
GET /api/v1/resources/{rid}/enhanced
GET /api/v1/resources/{rid}/embed-link
GET /api/v1/resources/{rid}/audio
GET /api/v1/resources/{rid}/wordcloud.svg
POST /api/v1/resources/{rid}/reprocess
POST /api/v1/resources/{rid}/align
POST /api/v1/resources/{rid}/share
DELETE /api/v1/resources/{rid}/share
GET /api/v1/resources/{rid}/shares
DELETE /api/v1/resources/{rid}/shares/{id}
GET /api/v1/resources/{rid}/export.{fmt}
GET /api/v1/resources/{rid}/evidence.pdf
PATCH /api/v1/resources/{rid}/segments/{idx}
POST /api/v1/resources/{rid}/segments/{idx}/split
POST /api/v1/resources/{rid}/segments/{idx}/merge
GET /api/v1/resources/{rid}/edits
GET /api/v1/resources/{rid}/outputs
GET /resources filters, sorts and pages on the server, over every asset you can read (in the namespaces you have
a role in, and in the collections you were given a role on), each with your role on it. Filters combine with AND;
repeat a parameter that takes several values (?status=new&status=error) to match any of them.
| Parameter | |
|---|---|
ns |
one namespace (default: every namespace you can read) |
collection |
a collection's id: the assets in it and in the collections inside it |
q |
words that must all appear in the title, the namespace's name or a person's name |
status |
new, transcribed, diarized, analyzed, error, and two job states: processing (a job is queued or running) and failed (the latest job failed) |
attention |
true: only assets that need a person (errored, latest job failed, or a voice match waiting for review) |
processing |
true: only assets with a job queued or running |
speaker |
person ids |
from, to |
the asset date, YYYY-MM-DD, both days included; assets without a date don't match |
min_duration, max_duration |
seconds: at least min_duration, shorter than max_duration |
media |
audio, video, transcript (text without media), document or image |
access |
public, restricted or private: the asset's own setting, else its namespace's default |
featured |
true: only featured assets; false: only the others |
tag |
tags, ignoring case |
origin |
where they came from: source:<id> (a connected source), upload, paste, iiif, folder (the archive's own folders) or file (another file imported by path) |
language |
language codes, ignoring case; none for assets whose language isn't known |
object |
kinds of object seen in them (person, car …; Objects), ignoring case |
face |
face identity ids: the assets they're seen in (Video) |
entity |
entity ids: the assets that mention them |
topic |
topic ids: the assets accepted as about them (not their narrower topics; POST /find brings those) |
content_type |
content type keys (podcast, scan, email …; content types) |
emotion |
emotions heard in them, ignoring case |
edited_by |
me: assets you edited (corrected a line of the text, changed the catalogue record, or renamed) |
sensitive |
where NSFW classification is on: only the ones flagged NSFW, exclude them, or include them. Without it, flagged ones are left out where their namespace hides them (display: hide) |
located |
true: only assets known to have been taken somewhere (a place, Where an asset was taken) |
bbox |
west,south,east,north in degrees (GeoJSON's order): only assets taken inside that box; west past east crosses the antimeridian |
sort |
date, title, duration, speakers, status or importance; - in front for descending (default -date). Assets without the value come last either way |
limit, offset |
one page (default 500 rows, at most 1000) |
The body is the page's rows; the X-Total-Count header says how many assets match on all pages.
Rows also say where each asset came from (origin, and origin_name: the source's name, or e.g. "Uploaded"),
its language when known, the collection it lives in (collection, collection_name) and where it was taken
(place: {lat, lon}, or null). GET /resources/map-tiles (anyone signed in) gives the tiles the web app's map draws
on: url, an XYZ address with {z}, {x} and {y} (null for none), and its attribution (Settings → Maps). GET /resources/{rid}
adds collection_path: the collections from the top of the namespace down to its own, [{id, name}].
POST /resources/collection {recordings, collection} moves assets into a collection of their namespace (editors
of each); an asset of another namespace is a 400 (move it to that namespace first). It answers how many moved;
their IIIF Manifests change (partOf), so harvesters see an Update. Audited as recording.collection.
GET /resources/origins, GET /resources/languages and GET /resources/objects (ns for one namespace) list the
origins, languages and kinds of object of the assets you can read with how many have each, most first, for the
Library's Source, Language and Objects filters. GET /resources/origins?storage=true adds the space each origin takes
on this server, read from the disk: media_bytes, the assets' own files (the media or document, and supplementary
files), and made_bytes, what Lens made of them (frames, pages, reports, exports, cached copies of files on storage
connections). Admin › System health shows it under Disk.
PATCH /resources/{rid}/segments/{idx} corrects a line of text: its text, its speaker (a person id in the
namespace, or null), or both (editors). POST …/segments/{idx}/split {at, t?, speaker?} splits a line in two at
at, a position in its text moved back to the start of the word it's in. The second part starts at t (ms, inside
the line) or else when its first timed word was said (or as far into the line's time as at is into its text), and
keeps the line's speaker unless speaker is sent. POST …/segments/{idx}/merge joins a line with the next one: both
texts (with a space between them, none in scripts written without spaces) and their words, from the first's start to
the second's end, with the first's speaker. A split or merge renumbers the lines after it, and what points at line
numbers follows: the edit history, people's entity corrections and the chapters. Each change re-analyses the
asset (job), is audited (transcript.edit, transcript.split, transcript.merge) and is kept in GET …/edits,
the latest first, with kind (split, merge, or null for a correction); a merge keeps where its second line
started (after.at, after.t) to split it there again.
An asset's summary (the Summarize step's, also in GET …/player) has summary, key_points and
action_items, topics, people, sentiment and importance (1–5). Key points and action items are
{text, who?, t0?}: who will do it, and t0 is where the line they come from starts (ms), when the model gave it;
summaries made before they had times hold plain strings.
In GET …/player, a line whose words have timings from transcription carries them as w: [c0, c1, t0, t1] for
each word, a character range of its text and when it was said (ms), with a fifth, how sure the engine was of the word
(0 to 1), when it said. A corrected line keeps the timings of the words it still has. unsure_below is the threshold
the web app marks words under (transcribe.confidence_highlight), or null when marking is off
(Words transcription wasn't sure of).
Assets carry tags. PATCH /resources/{rid} with {"tags": [...]} replaces an asset's tags (editors; at most
20, 40 characters each; whitespace is collapsed and repeats are dropped, ignoring case). POST /resources/tags with
{"recordings", "add", "remove"} changes the tags of several at once (editors of each one's namespace) and says how
many changed. GET /resources/tags (ns for one namespace) lists the tags in use with how many assets have each,
most used first.
PATCH /resources/{rid} with {"title": …} renames an asset (editors; whitespace is collapsed, at most 200
characters), and with {"language": …} sets its language (An asset's language). It is audited as recording.rename; the asset's report page follows the new title and is rebuilt,
and a published asset shows up as an Update in the IIIF change feed.
DELETE /resources/{rid} deletes an asset (owners). Everything Lens made from it goes: its text and analysis
(segments, speaker turns, chapters, entity mentions, terms, edits), video shots, text on screen, face tracks and
frames, outputs, reports and exports, shares, permissions, requests for access, and its place in IP groups, fixed
collections, chat scopes and batch runs that haven't started it. People and faces only it had are removed unless
someone named them. Its waiting jobs are cancelled; while a job is running on it the answer is 409, and a worker that
finds its asset gone stops. The media file stays where it is: scans skip the same path and the same file elsewhere,
and watched folders skip the same remote file, even when it changes; importing it on purpose (the Import dialog,
lens import, a IIIF manifest) brings it back. A public asset shows up as a Delete in the IIIF change feed. It is
audited as recording.delete with its title, namespace and path.
POST /resources/{rid}/move with {"namespace", "collection", "rediarize", "revoke_shares"} moves an asset to
another namespace (owners of its namespace, editors of the new one), into collection there (default: the new
namespace's default collection; 404 for a collection of another namespace). It keeps its text, media, frames, outputs, the people given
permission on it and its share links (revoke_shares: they stop working). Its IIIF manifest stays as it was: what it
had from its old namespace (default access and open parts, the metadata profile's defaults) is pinned on it wherever
the new namespace would change it, kept in its metadata history and listed in pinned. People and faces are matched
by name in the new namespace, or start there with this asset's voice and face (rediarize, audio only: identified
again from their voices instead); unnamed ones left with nothing in the old namespace are removed. Its entity mentions
and per-mention corrections start over: analysis runs again in the new namespace (job). Its report pages and exports
move to the new namespace's folders. The old namespace's scans and watched folders don't import the file again, and
its IP groups no longer open the asset. 409 when the new namespace already has the same file or a job is running
on it. Audited as recording.move.
Share links (editors): POST /resources/{rid}/share with {"days"} (1–3650, default 30) makes one and returns its
id, token, embed (/embed/<id>?s=<token>) and short (/s/<code>, ten characters, the same player); only their
hashes are kept, so the addresses are shown this once. GET /resources/{rid}/shares lists the links, newest first:
active (neither revoked nor expired), revoked_by/revoked_at, short (links from before short links have
none), plays (times its player started playing, once per page load; previews in Lens itself don't count) with
played_at, and embedded_on: the sites whose pages framed its player (origin, opens, last_at, most recent
first, up to 50 per link), from the browser's Referer when it reports a frame. DELETE /resources/{rid}/shares/{id}
revokes one link (404 if the asset has no such link), DELETE /resources/{rid}/share every one that still
works; both are audited as share.revoke. A link that doesn't work (expired, revoked, mistyped, or its asset is
gone) opens a neutral page with status 410 that says nothing about the asset.
GET /resources/{rid}/access says who may see an asset (members): access (public, restricted or
private), open (the parts a public asset opens to everyone: media, transcript, index), featured,
inherited (the access comes from the namespace), the namespace's default and downloads. PUT changes any of
them (owners); null follows the namespace again. downloads: false keeps the asset's files (its own file, the PDF
made of it, its supplementary files) for its owners: everyone else gets 403 from /media, /pdf and …/files/{id}/download
but still plays it through /audio, sees its pages and reads its text (Downloads).
GET /resources/{rid} and GET /resources/{rid}/files say downloads and can_save (whether you may save them). Changes are audited as recording.access, kept in the metadata history, and
announced in the IIIF change feed. Access explains what each level lets people do.
/resources/{rid}/permissions lists the people given permission on the asset (owners), each with expires_at and
expired. POST with {"email": …, "expires_at": …} gives it to the account with that address until then (404 when
there is none, 400 for members of the namespace, who already see all of it, and for an end that has passed); giving it
again changes the end. PATCH …/{account} {"expires_at": …} changes only the end (null: none), which brings an
ended permission back; DELETE …/{account} takes it away. They answer with the list and are audited as
recording.permission.give, recording.permission.change and recording.permission.take. /resources/{rid}/requests lists the requests for access
(owners); approving one gives permission, declining lets the person ask again (audited as
recording.request.approve and recording.request.decline).
/resources/{rid}/ip-groups lists the namespace's IP groups with opens: whether visitors from their addresses see
all of the asset (owners). PUT …/{gid} opens the asset to a group that opens chosen assets (400 for one
that opens everything already), DELETE …/{gid} closes it again (404 when it wasn't open). Both answer with the list
and are audited as recording.ip_group.open and recording.ip_group.close.
An asset's language¶
Transcription keeps the language it hears on the asset (language): SenseVoice's, the one Whisper detects when
Settings → Transcription leaves the language on auto, or a speech provider's, as a short code such as en. A person
can set or correct it as a BCP 47 tag, with a region when it matters (en-GB or en-US, pt-BR or pt-PT):
PATCH /resources/{rid} with {"language": "en-gb"} (editors) checks the tag and writes it the usual way (en-GB,
zh-Hant-TW, es-419); one that isn't a tag is a 400. GET /resources/{rid} gives language and language_set,
true when a person set it: transcribing it again, importing a transcript over it or reading it again keeps their
language instead of the detected one. {"language": null} forgets it, so the next transcription says again. Changes
are audited as recording.language, and a published asset shows up as an Update in the IIIF change feed (its
manifest and Dublin Core record give the tag). The MCP tool set_language does the same, and the asset's Details tab
in the web app shows the language by name with its tag, which editors change there. The Language filter counts en-GB
apart from en; a pipeline step's languages condition of en takes en-GB and en-US too.
Lining up a transcript with its audio¶
A transcript imported without times (plain text, Word, PDF, Markdown, Name: text lines without times) has times
estimated from the text, and GET /resources/{rid} says so: times is estimated. Attaching its audio keeps them.
POST /resources/{rid}/align (the process permission) lines the transcript up with its audio in the background:
202 with the job, which transcribes the audio as the transcribe step does (the same engine, activity ledger and
budgets; a heavy step, so quiet processing hours apply), gives each line the times of its words, then runs analyze,
embed and report again. The text and speakers stay as they are. Afterwards times is aligned and aligned is
{at, matched, engine}: when, the share of the transcript's words heard (0 to 1) and the engine that heard them.
Asking while it runs gives the same job; 400 when the resource has no audio, or its lines' times came with it or from
transcription (times is null). Audited as recording.align; the MCP tool align_transcript does the same
(Lining up a transcript with its audio).
Documents and images¶
A document (a PDF; a Word, PowerPoint or spreadsheet file, OpenDocument or RTF; text or Markdown; a saved web page; an
email, .eml or .msg) or an image (JPEG, PNG, TIFF, WebP, GIF or BMP) is a resource of its own: its source is
document or image, and so is media_kind in the list, with its pages. A document that isn't a PDF is made into
one first (Configuration): the resource keeps its own file
(GET /{rid}/media), and the PDF it's read from is GET /{rid}/pdf (primary.pdf in its files, and rendition:
{from, by} on the resource says how it was made). Nothing a document refers to is fetched while converting it. An
email's email is its subject, from, to, cc and date; its subject becomes its title and its date when it was
made, unless someone named or dated it. Its attachments are kept as its files (role attachment), and those Lens can
read become resources of their own beside it, each with attached_to: {resource, file, title} (the email, and its
title when the caller may see it); an attachment file's resource is the resource it became, or the one it was already
in the namespace (documents.attachment_resources). Transcribing an email again keeps and makes nothing twice.
GET /uploads/limits says what the server can convert (convert: {office, pages, msg, tika}); with an
Apache Tika server set, what nothing else can convert is read by Tika as text without
pages (engine text:tika), and every document has described, what it says about itself as Dublin Core terms
(POST /settings/documents/tika/test checks the server). An upload it can't read is
refused, saying what it needs, and the files that can be read as plain text too (PDFs, Word, text and Markdown) can be imported
as text instead (POST /import). Its pipeline's read step (its transcribe step, for a document or an image:
no speech model is fetched for one) draws its pages and reads their text: a PDF's own text, block by block with where each block is on its page (poppler's
pdftotext; pypdf without poppler, when there are no pages to look at), and by OCR (video.ocr_engine) the pages with
fewer than documents.ocr_below_chars characters of text, drawn sharper for it. An image is one page (a TIFF one per
frame), read by OCR. Diarize is left out for them, shots and text on screen skip them; analyze, summarize and report run as for any text.
Configuration has the settings.
The text becomes the resource's segments, one per block, so search, chat, entities, notes and corrections
(PATCH /segments/{idx}) work on it as on a transcript. A block's times are only a reading pace; what places it is its
page: GET /{rid}/player gives each segment p (its page, from 0) and b (where it is on the page, [x, y, w, h] as
fractions, when known), and pages: each page's idx, width and height (pixels of its image), signed links to its
image and its thumbnail (none when it couldn't be drawn), how its text was read (pdf or ocr), its chars and
its label (the PDF's own name for it, when it isn't its number). media says its kind, its pages and the first
page's size. GET /{rid}/media is the file itself, as a download that browsers don't run. A summary's key points and
action items cite pages: each has the page it comes from. The web app opens a document at a page with
/resources/<id>?page=<n> (from 1).
Storage sources bring documents and images in too (sources, imports): their files stay on the
source, like audio and video. Where a namespace looks for faces (Video), the faces step looks at each page
drawn: a track's spans, first_ms and boxes then count pages (from 0) rather than milliseconds, and screen_ms is
how many pages it's on (the namespace's faces don't count those as time on screen). Published, a document is a IIIF
Manifest with a Canvas per page (IIIF), and its public page shows its pages and its text page by page
(public).
Evidence PDFs¶
GET /resources/{rid}/evidence.pdf and GET /collections/{cid}/evidence.pdf are a resource, or the ones in a
collection you can read (a list in its own order, a filter's oldest first; 50 at most), as one PDF to hand on, made by
anytopdf. Each resource goes in as its own file: a document or an image as it is, a
captured web page as captured, audio or video with Lens's transcript of it (its speakers' names on each line), and a
text-only asset as its text. After it come the pages of what Lens found about it, a text file of its own
named <title> - what Lens found.txt. The PDF is searchable PDF/A-3 with its text and chunks embedded, and its last
page is a provenance page giving every file's SHA-256 and size, so whoever gets it can check each part against the
file it came from. It holds no paths of the server (anytopdf's share profile), is made when asked for, and nothing
of it is kept. Anyone who can read the resources can have it (share links and signed links can't).
include picks its parts (repeat it for several, ?include=file&include=summary); without it, every part but
notes goes in. In the web app, a resource's Export menu has Evidence PDF (the default parts) and Choose what goes in
it…, and a collection's page has the same beside its Evidence PDF button; the picks are remembered in the browser.
| Part | What goes in |
|---|---|
file |
its own file, as above |
details |
what it is, when it was recorded and added, its length or pages, size, language, words, content type, where it came from, its status and NSFW classification (where that is on); an email's subject, sender, recipients and date; a web page's address and when it was captured; what a document says about itself (Tika) and how its PDF was made |
metadata |
its description as saved over what it says about itself: title, description, date, language, creators, contributors, subjects, identifiers, rights, attribution, provider, related links, access, Dublin Core terms and other statements, and its custom fields |
tags |
its tags |
topics |
the topics it's about: accepted ones, then suggested ones, each saying how it came |
summary |
the summary a model wrote, its key points and follow-ups (each with where it comes from), the people and topics it names, its overall tone and importance |
analysis |
what its pipeline's model steps saved, by key, with the model |
sections |
its sections, with where each starts |
speakers |
who spoke, for how long and what share of the speech, their turns, words and pace, and how each was recognised |
emotion |
the share of the speech time of each emotion, overall and by speaker, and the sounds heard; each transcript line is marked with its emotion and sound, such as "(Happy, Laughter)" (neutral speech isn't marked) |
entities |
the names it mentions by type (people, organisations, places, dates …), each with how often |
keywords |
its keywords |
visual |
its shots, the faces found (and who, when recognised), the objects seen, what a model said each page or shot shows, and the text on screen, with where each is |
places |
the places and dates anytopdf found in it and where a photo was taken (members of its namespace only, as on its page) |
notes |
the highlights, shared notes and comments on it, with who wrote them (names only); never anyone's private notes |
What a model wrote (the summary, analysis and descriptions) says so on its page. A list longer than 1,000 lines (text on screen in a long video, say) shows the first 1,000 and says how many more there are. Making these pages calls no model: they're read from what Lens already stored.
anytopdf runs as it does for conversions (no network, plugins or config file of its own); if it isn't here it's fetched on first use, unless Settings › Documents says never to use it (503). A conversion node takes one file per job and its own options: a single document goes as it is, and more go as one zip, with audio and video as their transcripts only.
notes¶
GET /api/v1/resources/{rid}/notes
POST /api/v1/resources/{rid}/notes
PATCH /api/v1/resources/{rid}/notes/{nid}
DELETE /api/v1/resources/{rid}/notes/{nid}
Notes on an asset, for people with a role in its namespace (share links and signed links don't reach them). POST
with text (up to 5,000 characters) and, for a note about a moment, t0 and t1 (ms from the start; t1 defaults
to t0 and stops at the end of the asset) and the quote picked in the text (up to 1,000 characters);
without t0 the note is about the whole asset. Anyone who can read the asset can write notes, up to 500 each
on an asset. A note is its writer's: only they see it, unless they share it (shared: true, which needs editor
access) with everyone who can read the asset. GET lists yours and the shared ones: notes about the whole
asset first, then by moment, each with its writer (created_by, created_by_name), mine, can_delete and
edited_at (when its text last changed). Only its writer changes a note (PATCH with text or shared); its writer,
or an owner of the namespace for a shared one, deletes it. Sharing, unsharing and deleting a shared note are audited
(note.share, note.unshare, note.delete, on the asset). Notes move with their asset and go when it's
deleted.
notebook¶
GET /api/v1/notes
POST /api/v1/notes
POST /api/v1/notes/today
GET /api/v1/notes/templates
POST /api/v1/notes/templates
GET /api/v1/notes/search
GET /api/v1/notes/export
GET /api/v1/notes/deleted
POST /api/v1/notes/deleted/{pid}/restore
DELETE /api/v1/notes/deleted/{pid}
GET /api/v1/notes/{pid}
PATCH /api/v1/notes/{pid}
DELETE /api/v1/notes/{pid}
POST /api/v1/notes/{pid}/move
POST /api/v1/notes/{pid}/copy
PUT /api/v1/notes/{pid}/pin
DELETE /api/v1/notes/{pid}/pin
GET /api/v1/notes/{pid}/markdown
GET /api/v1/notes/{pid}/history
GET /api/v1/notes/{pid}/history/{vid}
POST /api/v1/notes/{pid}/history/{vid}/restore
GET /api/v1/notes/{pid}/files
PUT /api/v1/notes/{pid}/blobs
GET /api/v1/notes/{pid}/blobs
DELETE /api/v1/notes/{pid}/files
Note pages (Notes), apart from the notes on an asset above. GET /notes?ns= is a namespace's page tree
(all=true adds the pages of things). GET /notes/{pid} gives a page with its text, links, backlinks, path (the
notes it's inside, from the top) and children (the notes inside it) (On a note). today gives
the namespace's note for date, made the first time it's asked for (Today's note).
templates lists the notes inside the namespace's Templates note, and POST /notes/templates makes that note the first
time; copy makes a new note from a page, with its text, drawings, files and filing (Templates).
search?q= finds pages by the words in their title, summary or text, title
matches first, each with the words around the match (Finding notes). DELETE /notes/{pid}
moves a page to recently deleted for 30 days: deleted lists them, restore puts one back where it was and
DELETE /notes/deleted/{pid} deletes one for good (Deleting). move puts a
free note inside parent before before. PUT …/pin pins a page for you (pinned on each page says whether you
did) and DELETE …/pin unpins it (Pinned notes). markdown gives a page as a Markdown file and
export?ns= every page of a namespace as a zip of them (Downloading). history lists what the page was before
each change, and restore puts an earlier version back (what it was becomes a version too). files
lists the files and images attached to a page; blobs uploads (in chunks) and reads them, and DELETE …/files?key=
removes one.
the graph as files¶
GET /api/v1/tree?path=
GET /api/v1/tree/file?path=
GET /api/v1/shares/s3
POST /api/v1/shares/s3/keys
DELETE /api/v1/shares/s3/keys/{key_id}
GET /api/v1/shares/s3/keys
The archive as folders and files (The graph as files): tree lists a folder (an empty path lists your
namespaces; in each, Collections, Topics and Entities), tree/file reads a file with byte ranges. Read-only; everyone sees what they can read in the web app.
shares/s3 is the S3 and WebDAV servers' state and your access keys; keys are
made only while signed in.
comments¶
GET /api/v1/resources/{rid}/comments
POST /api/v1/resources/{rid}/comments
PATCH /api/v1/resources/{rid}/comments/{cid}
DELETE /api/v1/resources/{rid}/comments/{cid}
Comments on a resource: a conversation everyone with a role on the resource (in its namespace, or on its collection)
reads and joins; share links and signed links don't reach them. POST with text (up to 5,000 characters) and, for
a comment about a moment or a passage, t0 and t1 (ms from the start; a document's pages have a reading-pace
clock, so t0 is a place on a page; t1 defaults to t0 and stops at the end of the resource) and the quote
picked in the text (up to 1,000 characters); without t0 the comment is about the whole resource. A comment starts
a thread; POST with parent replies on that comment's thread (a reply to a reply goes on the thread too, and a
reply is about what its thread is about, so its moment is ignored). Anyone who can read the resource can comment, up
to 1,000 each on a resource. GET lists them threaded: each thread (the ones about the whole resource first, then by
moment) followed by its replies, the earliest first, each with its writer (created_by, created_by_name), mine,
can_resolve, can_delete and edited_at. Only its writer changes a comment's text (PATCH with text). A thread
is resolved and reopened on its first comment (PATCH with resolved), by its writer or an editor of the resource;
the thread says who resolved it (resolved_by, resolved_by_name, resolved_at). Its writer, or an owner of the
resource (of its namespace, or an admin of its collection), deletes a comment; a thread's first comment takes its
replies with it. Resolving, reopening and deleting are audited (comment.resolve, comment.reopen, comment.delete
with whether its writer did it and how many replies went, on the resource). Comments move with their resource and go
when it's deleted.
highlights¶
GET /api/v1/resources/{rid}/highlights
POST /api/v1/resources/{rid}/highlights
PATCH /api/v1/resources/{rid}/highlights/{hid}
DELETE /api/v1/resources/{rid}/highlights/{hid}
Passages of a resource its editors mark in colour, for everyone with a role on it. POST (editors) with the passage
(t0 and t1, as for comments; t1 defaults to t0), the quote picked in the text (up to 1,000 characters), a
colour (yellow, the default, green, blue or red: the web app has a token for each) and a label of what it
marks (up to 200 characters). Up to 1,000 on a resource. GET lists them by passage, each with who made it
(created_by, created_by_name), mine and can_edit. Any editor of the resource changes one (PATCH with
colour or label; an empty label clears it) or deletes it; deleting is audited (highlight.delete, on the
resource). Highlights move with their resource and go when it's deleted.
files¶
GET /api/v1/resources/{rid}/files
POST /api/v1/resources/{rid}/files?role=&name=&language=&label=
PATCH /api/v1/resources/{rid}/files/{fid}
DELETE /api/v1/resources/{rid}/files/{fid}
GET /api/v1/resources/{rid}/files/{fid}/download
GET /api/v1/resources/{rid}/files/{fid}/lines
GET /api/v1/resources/{rid}/files/translate?language=
POST /api/v1/resources/{rid}/files/translate {"language": "fr"}
A resource's files: its primary file (primary: the audio or video its pipeline runs on, none for text
without media) and any number of supplementary files beside it, each with a role, and a language, label and
description of its own:
role |
Types | What becomes of it |
|---|---|---|
transcript |
.txt .md .json .jsonl .srt .vtt .docx .doc .pdf | another transcript, read into lines search finds |
captions |
.vtt .srt | lines, and WebVTT captions in IIIF |
translation |
as transcripts | lines in another language |
index |
WebVTT or SRT chapters, JSON, OHMS XML, text whose entries start with a time | entries with a title, synopsis and keywords, and a table of contents in IIIF |
thumbnail |
.jpg .jpeg .png .webp .gif | the Manifest's thumbnail in IIIF |
attachment |
anything | a file to download |
People who can read the resource list its files (GET, each with a signed download link that works on its own, as
media links do) and read the lines of transcripts, captions, translations and indexes (…/lines, in order, with
offset and limit up to 1,000). A line has t0/t1 in ms, which are null when its file doesn't say when its lines
are (timed: false, such as prose in a PDF); an index entry also has its title, synopsis and keywords.
Editors add a file as the raw request body (application/octet-stream), naming it and its role in the query, up to
server.max_upload_mb. 400 when its type doesn't fit the role, when it can't be read as its role (no text, more than
20,000 lines, a PDF without a text layer), or when the resource has 100 files already; 413 past the limit. PATCH
changes its role (reading it again), language, label or description (null clears the last three); DELETE deletes it
and its lines. Adding, changing and deleting are audited (file.add, file.update, file.delete, on the resource).
Downloads come as attachments and are never run by a browser: pages, scripts and SVG are sent as plain bytes. Files
stay with a resource that moves to another namespace and go when it's deleted.
public says whether everyone may download a file. A public resource opens its files with its parts
(Access): transcripts, captions and translations with its text, indexes with its index,
thumbnails with its media. Attachments always need permission.
translating a transcript¶
Lens can make a translation as well as keep an uploaded one. POST …/files/translate with a language (a code such
as fr, de or pt-BR) has the configured model translate the resource's transcript, and keeps the result as a
supplementary file with the role translation and that language, exactly like an uploaded one: search finds its
lines, the Files tab shows them and IIIF publishes it, and it downloads as Lens's own transcript JSON (lens/1, which
imports straight back in), named after the resource (Interview (fr).json), with a description that says the model
made it. Each line keeps its original's times and speaker, so a timed transcript gives a timed translation.
It runs only when someone asks for it, never on its own: in the web app from the Files tab's Translate… (the
language you read, from your browser, is filled in, unless the transcript is already in it), through the API or the
MCP tool translate_transcript, or by the assistant's translate_transcript once the person approves it. It runs as
a background job (202 with the job; asking again while it runs gives the same job) after what the resource's job
still has to do. The lines go to the model in batches of up to 40 lines and 6,000 characters, numbered and marked as
data (prompt injection), so instructions written in a
transcript are translated, never followed. A line the model doesn't give back is asked for once more on its own, then
kept as it was, and the file's description says how many were.
Every call is in the activity ledger, counted for the job, the resource and its namespace. A budget the run would go over holds the job until someone picks, as for any job; before each batch Lens looks again, so a budget used up part-way stops the translation (the job fails saying so, and no file is kept).
GET …/files/translate says beforehand what it would take: the transcript's lines, the model calls and tokens in
llm, and the cost at the model's price in Settings (llm.cost, null without a price), always an estimate
(estimate is true, with its basis); problem says why it can't run now (no model configured, no transcript, a
budget used up), and with language, job is the one already translating it into that language. POST needs the
right to process the resource: 400 without a model or a transcript, for a language that isn't a code, or one the
transcript is already in; 409 over budget. Asking is audited (file.translate).
imports¶
POST /api/v1/import
POST /api/v1/import/preview
POST /api/v1/import/web
POST /api/v1/import/web/links
GET /api/v1/import/runs
GET /api/v1/import/runs/{id}
POST /api/v1/import/runs/{id}/undo
POST /import queues the namespace's pipeline after the import, or the saved pipeline named by pipeline (any of
GET /pipelines; 400 for one that doesn't exist, before anything is saved). Every import takes a collection of the
namespace to put the assets in (default: its default collection; 404 for one of another namespace): POST /import,
POST /import/source, POST /uploads and POST /import/iiif. A transcript imported without its audio has nothing to
transcribe and its speakers came with it, so its pipeline's transcribe and diarize steps are left out and no speech or
voice model is fetched for it.
POST /import, POST /import/web, POST /import/web/links and POST /uploads also take a content_type: the
content type the asset is (a key from GET /content-types), instead of the one its
file is recognised as, so that content type's pipeline runs from the start. It has to be one of the asset's base type
(text for the imports here; for an upload, the file's: audio, video, image or text), else 400, naming the ones that
fit, before anything is saved. The asset's Details tab shows it as chosen, and it can be changed there later.
POST /api/v1/import/source {source, paths, namespace, pipeline?, collection?, documents_as?} imports chosen files of
a storage source now, rather than watching their folder (admins, like sources; up to 500 paths, as
GET /sources/{sid}/browse lists them). Audio, video, documents and images stay on the source and run the namespace's
pipeline, or pipeline; transcripts are imported. A PDF, Word, text or Markdown file is a document unless
documents_as is transcript (its text only), or the server can't make a PDF of it. Each path gets a result: queued (with recording and job), already (the namespace has it from this
source, or, for a folder on this machine, has the same file from anywhere: another folder, an upload or a scan, by
what's in it, so it isn't read twice; with that recording), skipped (a folder, or not audio, video, a document, an image or a transcript) or error. Choosing a file
whose asset was deleted brings it back. The files queued are one import run (import_run). Audited
as import.source.
Import runs¶
A bulk import is one import run, so a mistaken one is undone whole rather than asset by asset: a folder scan that
finds new files (scan), a zip or tar archive whose files become resources of their own (archive: the archive and
those resources), a list of links or a bookmarks file (links, POST /import/web/links) and chosen files of a source
(source, POST /import/source). Every asset a run made carries its id as import_run (GET /recordings/{id}), and
the import's answer names it (import_run). Any other upload, a pasted transcript or one web page makes no run,
and assets imported before runs existed have none.
GET /api/v1/import/runs?limit= lists recent runs, newest first: the ones you started and those in namespaces where
you may delete assets (everyone's, for admins), each with kind, namespace, source (what it read: the folders,
the archive, the links' file, the source and how many files), created_by, created_at, assets (how many it made),
left (how many are still here) and status (imported, partly undone or undone). GET /import/runs/{id} reads
one (404 when it isn't one you'd see listed).
POST /api/v1/import/runs/{id}/undo deletes every asset the run made that is still here, each exactly as
DELETE /recordings/{id} deletes one (the media files stay where they are, and scans and watched folders don't import
them again). It needs the rights a delete needs on each one (the remove permission in its namespace, owners, or admin
of its collection): when any is one you may not delete, it answers 403 and nothing is deleted. An asset a job is
working on is left (busy) and its job asked to stop; undoing the run again takes the rest. It answers {run,
deleted, busy}. Lens keeps no recently deleted list of assets, so an undo can't itself be undone: importing the files
again brings them back. Audited as recording.delete for each asset (with the run) and import.undo for the run. The
Import screen lists recent runs with Undo on each, and the toast after a bulk import has Undo for the whole run; the
assistant has import_runs and undo_import, which waits for your approval.
Web pages¶
POST /api/v1/import/web {url, namespace, title?, collection?, pipeline?} captures a web page as a document (editors
of the namespace; admins may name a new one). Its pipeline keeps the page as it is then, as a PDF, and reads it like
any document (Documents and images): a link to a PDF is kept as it is, any other page is
printed by headless Chromium with its scripts run. Its web is {url, final, captured_at, how}: the address given,
the one it ended at after redirects, when it was captured, and printed or pdf. Its title is the page's own once it's
captured, unless title gave one. Capturing happens once: reading it again keeps the page it captured. An address the
namespace has a page from already isn't captured again: the answer is that resource, with duplicate true and no job.
Only public addresses are reached, on ports 80 and 443: the address is checked when it's given (400, saying why: not
http(s), a user name or password in it, another port, or a host with an address that isn't public), and every request
the page makes is checked again by a proxy inside Lens, which resolves each host itself and connects only to the
address it checked; redirects are followed to http and https addresses only, each checked. Chromium has no other way
out: it looks up no names itself, reaches no D-Bus, and WebRTC, which a page's script can start, may only go through
the proxy (never UDP straight to an address). documents.web_networks (Settings → Documents) adds networks that may be reached too, for an
intranet. Without Chromium on the server it answers 400 (GET /uploads/limits → convert.web). Audited as
import.web. The PDF is the resource's file (GET /resources/{rid}/media), named after the link
(annual-report.pdf) or the page's title (harbour-news.pdf).
POST /api/v1/import/web/links {namespace, text | urls | filename + data, folders_as_tags?, collection?, pipeline?}
captures one or many pages at once: pasted addresses (text, one per line; a line's other words become its title, #
lines are skipped), a JSON list of addresses (urls), or a file (base64 data): a list of addresses, a browser's bookmark export (the HTML file every browser
writes) or Chrome's own Bookmarks file (JSON). Each link becomes a page captured as above, titled with its bookmark's
name until it's captured, and tagged with the bookmark folders it was in (the innermost five, without the browser's
own: Bookmarks bar, Other bookmarks), unless folders_as_tags is false. At most 500 links at once (400 beyond); links
are taken in order, without repeats. It answers with each link's status: queued (with its recording and job),
already (a page captured from that address is in the namespace: its recording) or skipped (detail says why,
as the checks above would). The pages queued are one import run (import_run). Audited as
import.web once, with how many were queued.
Videos and podcasts¶
POST /api/v1/import/video {url, namespace, title?, collection?, pipeline?} imports a video or a podcast episode by its
address (editors of the namespace; admins may name a new one): a page on a video site, a podcast's episode page, or a
media file. An address the namespace has the video from already isn't downloaded again: the answer is that resource,
with duplicate true and no job. It's off by default: documents.video_urls (Settings › Documents) turns it on, and until then it answers
400. Its transcribe step downloads the media with yt-dlp (Unlicense), which Lens
fetches on first use (release 2026.08.19, checked against its SHA-256, into data_dir/models/) unless one is on PATH,
then transcribes it like an upload. One video is downloaded, never a playlist: the best single file with both sound and
picture, at most 720 lines high, so nothing needs joining, and at most uploads.max_mb. yt-dlp runs with no config
file, plugins or cache, and the same proxy as capturing a web page as its only way out, so only public addresses on
ports 80 and 443 (and documents.web_networks) are reached; the address is checked when it's given, as above.
The resource is audio until it's downloaded (the namespace's pipeline for audio runs, unless pipeline names one);
the video steps then see its picture. Its web is {url, media: true, final, site, by, captured_at}, and its title
the video's own unless title gave one. Downloading happens once: transcribing it again keeps the file, which is the
resource's media (GET /resources/{rid}/media). Audited as import.video.
Its own captions. With documents.video_captions on too (Settings › Documents › Use a video's own captions, off
by default), captions people wrote for the video come down with it (a site's subtitles, never its automatic ones) and
become its transcript, so nothing is transcribed and no speech model or provider is used. Of the languages it has,
Lens takes the one set for transcription, else the video's own, else the first; the file is kept next to the media
(web.captions names it and its language), encrypted like the rest when the namespace is, and the transcript's
engine is import:captions. A video without such captions is transcribed as before.
Import webhooks¶
An import webhook is an address other services push files, web addresses or text to (a scanner, a phone shortcut, an automation tool, a conversion node). What arrives lands in one namespace as if someone had uploaded or imported it there, and runs the hook's pipeline, else the namespace's.
GET /api/v1/namespaces/{name}/import-hooks
POST /api/v1/namespaces/{name}/import-hooks
PATCH /api/v1/namespaces/{name}/import-hooks/{hid}
POST /api/v1/namespaces/{name}/import-hooks/{hid}/token
DELETE /api/v1/namespaces/{name}/import-hooks/{hid}
GET /api/v1/hooks/import
POST /api/v1/hooks/import
POST /api/v1/hooks/import/{token}
A namespace's owners manage its hooks. POST {name, collection?, pipeline?, hand_back?} makes one and answers with its
token (lih_...), shown this once; only a hash is kept, and lists show its last four characters. PATCH renames it,
pauses or resumes it (enabled), turns PDF hand-back on or off (hand_back), or changes its
collection or pipeline (null for the namespace's own). POST .../token gives
it a new token and the old one stops at once; DELETE stops it altogether. What a hook imported stays either way.
Audited as import_hook.create, .update, .token and .delete.
Pushing needs only the token, as Authorization: Bearer <token> (or in the address, for services that can only be given
a URL; the address form ends up in proxy logs, so prefer the header). Three ways to send:
- A file as the raw body, named by
?filename=,Content-DispositionorX-Filename. Audio, video, documents and images become assets exactly as uploads do, duplicates included;.srt,.vtt,.json,.jsonland.icsare imported as text. - Multipart form files (field
file, up to 20), with optionaltitle,urlandtextfields. - JSON
{url?, text?, title?}: a web page or PDF kept as a document, as web pages are (a page needs Chromium on the server; a link to a PDF doesn't), and text imported as pasted text is.
The answer (202) is {namespace, items: [{kind, name, recording, job, duplicate, needs_pdf}]}. 401 for a token Lens doesn't know,
saying which kind it took it for (a hook's, an API token, a sign-in), 403 while the hook is paused, 413 over
uploads.max_mb, 507 when the disk can't hold the file. Each item is audited as import.hook, by import_hook:<id>
rather than a person.
Your own token works there too, for the web clipper, an agent or a script acting as you: an API
token with write access (la_...), an app's token (lo_..., with the write scope) or the web app's own, with
?namespace= naming one you can import into (the import permission; editors have it). Without ?namespace=, it's
the only namespace you can import into, else 400 listing them. A read-only token gets 403. What you push this way is
yours, audited as import.push, and isn't counted on any hook. Your own token only goes in the Authorization
header: the address form takes hook tokens only.
GET /api/v1/hooks/import says where a token's pushes go without importing anything:
{kind: "hook" | "person", hook, email, namespace, namespaces, write}. For a hook's token, namespace is the hook's;
for yours, namespaces lists every namespace you can import into and namespace is the one used without
?namespace= (null when there's a choice), and write is false for a read-only key.
curl -H "Authorization: Bearer $TOKEN" --data-binary @minutes.pdf "https://lens.example/api/v1/hooks/import?filename=minutes.pdf"
curl -H "Authorization: Bearer $TOKEN" -F file=@call.m4a -F title="Call with Sam" https://lens.example/api/v1/hooks/import
curl -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -d '{"url": "https://example.org/report.pdf"}' https://lens.example/api/v1/hooks/import
curl -H "Authorization: Bearer $LENS_API_TOKEN" -H "Content-Type: application/json" -d '{"text": "Notes", "title": "Notes"}' "https://lens.example/api/v1/hooks/import?namespace=pods"
PDF hand-back¶
A light server (a Raspberry Pi without LibreOffice or Chromium) can't make Word, PowerPoint and Excel files, emails or
saved web pages into PDFs, so it refuses them. A conversion node that can (anytopdf, or any service with LibreOffice)
can push them through a hook with hand_back on and send the PDF back. It's off on every hook until an owner turns
it on, and does nothing on a server that converts those files itself.
- Push the file as usual. Where this server can't convert it, it's kept, and its item says
needs_pdf: true, with nojob: the resource waits (its page shows that it waits,awaits_pdfonGET /recordings/{id}), and running what's waiting leaves it alone. - Convert the file you already have and push its PDF to the same hook with
?for=<recording>, as the raw body or the one file of a form. It becomes the document's rendition (made bypushed), the pipeline the file was pushed with runs, and its pages are drawn and read from that PDF. An email still gives its sender, recipients, subject and attachments, read from its own file. The item is{kind: "pdf", recording, job}, audited asimport.hook.
Only the hook a document came through can hand back its PDF, and only once, while it waits: 404 for any other recording, a person's own token gets 400, and a body that isn't a PDF gets 400.
curl -H "Authorization: Bearer $TOKEN" --data-binary @minutes.docx "https://lens.example/api/v1/hooks/import?filename=minutes.docx"
# {"namespace": "pods", "items": [{"kind": "file", "name": "minutes.docx", "recording": 42, "needs_pdf": true, ...}]}
curl -H "Authorization: Bearer $TOKEN" --data-binary @minutes.pdf "https://lens.example/api/v1/hooks/import?for=42"
Uploads¶
Audio, video, documents (PDF) and images go up in pieces, so a dropped connection costs one piece, not the file. A PDF
uploaded this way is a document; to import only a PDF's text, send it to
POST /import instead (the web app's Import asks which).
GET /api/v1/uploads/limits
POST /api/v1/uploads
GET /api/v1/uploads
GET /api/v1/uploads/{uid}
PUT /api/v1/uploads/{uid}?offset=
DELETE /api/v1/uploads/{uid}
GET /limits tells anyone signed in what can be uploaded: the types (uploads.extensions), the largest file
(uploads.max_mb), the piece size the web app sends (uploads.chunk_mb) and the largest text or transcript file for
POST /import (server.max_upload_mb).
POST /uploads {namespace, filename, size, title?, modified?, pipeline?, content_type?, collection?, recording?} starts one (editors of the namespace; admins
may name a new namespace, created when the upload finishes). With recording, a text-only asset, the file
becomes that asset's audio instead of an asset of its own (editors of its namespace; namespace can then be left
out; 409 when it has audio already; 400 for anything but audio or video). Processing says what runs then. pipeline
runs instead of the namespace's once a new asset is made, content_type is the asset's content
type (one of the file's base type, else 400; its pipeline runs) and collection is
where it goes (none of them with recording, which keeps its own). The same file uploaded again with a content type
gives the asset it already is that one. The name loses any folders and its extension is lowercased; modified
(the file's last-modified time in milliseconds) dates the asset when neither its name nor the file itself does
(When an asset was made). 400 for a type not in
uploads.extensions, 413 over uploads.max_mb, 507 when the server's disk can't hold it with 512 MB to spare.
PUT /uploads/{uid}?offset= sends the next piece as the raw request body (application/octet-stream), starting at
offset: the upload's offset is how many bytes have arrived. Pieces stream to a partial file under
data_dir/uploads/.partial; a piece that breaks off is dropped whole, so after a dropped connection the client gets the
upload and sends from its offset. 409 when offset isn't where the upload has got to, or while another piece of it
is arriving. The piece with the last byte moves the file to data_dir/uploads/<namespace>/<uid>/<name>, makes it an
asset and queues the namespace's pipeline (or attaches it, adding the steps that need media to the asset's job):
the answer has state: "done", recording and job. When the namespace
already has the same file (uploaded, scanned or imported before), no second asset is made: duplicate is true and
recording is that one, which gets its media back if it had lost it. Audited as upload (with attached in the
detail). Sending the last piece again
answers the same.
GET /uploads lists your unfinished uploads, newest first: choosing the same file for the same namespace again carries
on from its offset. Only the person uploading (and admins) can see, send to or DELETE an upload; DELETE throws away
what arrived. An upload nobody has sent a piece to for uploads.expire_hours is removed with its partial file. Deleting an
asset later leaves its uploaded file in place, like any other media file.
search¶
GET /api/v1/search
GET /api/v1/search/terms
POST /api/v1/find
POST /api/v1/classify
GET /api/v1/similar
GET /api/v1/graph
GET /api/v1/mentions
GET /search?q= finds the moments where the words appear (in an asset's text or transcript, on screen in a video, in a resource's
supplementary transcripts, captions, translations and indexes, in what a model says its shots and pages show, or in
what a pipeline's model step wrote about it) in the
namespaces you can read, best first: every word
(English stemming), "quoted phrases" as written, OR between alternatives, NOT or - before a word or phrase to leave out the lines
with it; ns, speaker, emotion, event (a sound heard, such as laughter, applause or music, typed any way; music
is stored as BGM) and recording narrow it, and object keeps to the assets a kind of object is seen in
(Objects); from and to (days, both included: when the asset was recorded, or added when its file
doesn't say), tag, origin (upload, paste, iiif, folder, file or source:<id>, as GET
/recordings/origins lists them) and language (a code, or none for not known) keep to the assets that have them,
exactly as the Library's filters of the same names do, on every kind of hit (said, on screen, in files …); repeat
tag, origin or language for several (any of them). None of them is set unless asked for, so a search without
them is as it was;
limit/offset page through it. total counts the moments ranked so far (capped when there may be more).
With facets=true it also counts all the matching moments, whatever the page, by namespace, person, emotion, sound
(events; plain speech isn't one) and asset (facets: up to 50 values each, most first, and moments), and lists the kinds of object seen in the
assets they're in, with how many of those assets each is in (objects); past 20,000 moments the counts cover
20,000 of them (partial).
In the web app, a result links to the asset with the words searched for (/resources/<id>?t=<seconds>&q=<words>,
without filters, left-out words or OR): it opens with Find in its text showing them, on the match at that moment,
or on the longest of the words when the text doesn't have them together. The search page's filter panel starts with
Assets: From and To days, and a tag, a source and a language to pick (a list is left out when there's nothing to
choose between). Each is empty until picked, one pick searches again, and each can be typed in the box too
(from:2026-01-01, to:2026-03-31, tag:interview, origin:upload or a source's name, language:de or
language:German). They're kept in the address, so a link carries them, and in a saved search.
Each hit's source says where it was found: said (a line of the asset's text or transcript), screen (text on screen), page (a document's or an
image's text: its page, from 0, and its box on it), object (a kind of object, where it's first seen: its time, or
on a document's pages its page, with the frame and the box it's best seen in), described (what a shot or a page
shows, Descriptions: the shot's time, or the page, and its frame), generated (what a pipeline's
llm step, or a workflow's Save output node after an LLM prompt node, wrote about the asset: the output's
output_key and what wrote it, output_label, with no time or page; Pipelines and
templates) or file (a line of a supplementary file: its file, file_role
and file_label, and which line). A file's lines have no t0 when the
file doesn't say when they are; the web app opens those in the resource's Files tab. A speaker, emotion or event
filter keeps to said hits, and each hit says its emotion and event.
mode says how the words are matched (search by meaning): keyword (BM25),
semantic (passages an embedding model finds alike), hybrid (both, fused by rank) or auto (the default: hybrid
when search by meaning is set up and the query has no "phrases" or OR, else keyword). The reply's mode is how it was
matched, semantic whether search by meaning is set up, and meaning why it wasn't used when asked for. Each hit's
match is words, meaning (with its similarity, cosine, and shown at the passage's best line) or both.
GET /similar lists the resources most like one thing, best first, in the namespaces you can read and the
collections you were given a role on (similar content): a text (what was selected,
up to 2,000 characters; exclude leaves out the resource it was selected in), a recording, a topic or an
entity; give exactly one (422 otherwise, 404 for one you can't read). limit (default 10, at most 50) and offset
page through them. Each item says why it's alike in match (meaning, words, topics, entities), with its
similarity by meaning, up to three cites (moments to open: source, idx, t0 or page, and a snippet with the
key words marked), the key words it says, and the topics and entities in common. The reply's mode is meaning
when an embedding model compared them too, else words; terms are the key words looked for and meaning says why
it wasn't by meaning when search by meaning is set up. An asset is never listed as like itself.
POST /find finds the assets you can read by any attribute (finding by any attribute).
The body takes faces, speakers, entities and topics (ids or names), types, content_types (keys or labels),
date_from and date_to (2026-09-30, 2026-09 or 2026), min_duration and max_duration (seconds), tags,
languages, objects, emotions, status, origins, collection, sensitive, title (words in the title, the
namespace or a person's name), text (words said or written, as GET /search takes them) and namespace; sort
(relevance, the default with text, else -date; or date, title, duration, - for descending), limit
(default 20, at most 100) and offset. Filters combine with AND, the values of one with OR. The reply has total,
the page's items (rows as GET /resources lists them, each with why: [{by, name, moments, count}], by being
words, face, voice, entity, topic, content_type, object or emotion, and each moment a t0 and t1 in
ms or a page from 0, with its text when it has words), resolved (what each name matched: [{id, name, namespace}]
per filter, and narrower_topics) and text (how the words were matched, as GET /search says, and whether the
moments looked through were capped). A name that matches nothing in the namespaces you can read is a 400 that names
the closest ones; an unknown field is a 422.
POST /classify says which label fits an asset or a text best (classifying). The
body takes recording (an asset you can read) or text (at most 20,000 characters), and labels (a list, with
label: what it means items, or {label: what it means}) or vocabulary (content_type or topic; a text's topics
need namespace); question (at most 500 characters) asks it your way, and apply acts on a sure answer (an editor
of the asset). The reply has choice, confidence (0 to 1), sure, ranked ([{label, p}], best first, with
the content type's key or the topic's id), by (jev, laya, llm, or only option when there is one), vocabulary, question and applied
(content_type, topic_suggested, tag, or null when nothing changed), plus the chosen content type's key or
topic's id. Bad labels or a missing choice are a 400, an asset you can't read a 404, apply without editing a 403,
and no model set up to answer a 409.
Search has no prefix search (interp* looks for the word "interp"). GET /search/terms?prefix=interp lists whole
words found in the namespaces you can read (ns for one) that start with it, the most frequent first, with how often and
in how many assets (limit, default 8, at most 20); the web app offers them as "Try …".
people¶
GET /api/v1/speakers
GET /api/v1/speakers/{sid}/recordings
POST /api/v1/speakers/{sid}
POST /api/v1/speakers/{sid}/merge
POST /api/v1/speakers/{sid}/detach
POST /api/v1/merges/{mid}/undo
POST /api/v1/speakers/{sid}/link
POST /api/v1/speakers/{sid}/unlink
POST /api/v1/speakers/{sid}/not-same
People (everyone recognised by voice or face) live under /speakers.
GET /speakers?ns= lists the namespace's people (each with its suggested merges), its last merges (with by, the
recordings and segments that moved, and undone_by/undone_at), its links to other namespaces, and cross:
voices in other shared namespaces you can read that are likely the same person (score, best first). not-same
{with} says two entries aren't the same person (one namespace or two): the suggestion goes and isn't made again,
and a merge carries it over to the person merged into.
detach {recording} says the voice heard as this person in one asset is someone else: its lines there go to a new
person (speaker in the answer), this person's voice is worked out again from the assets they're still in, and the two
are marked not the same; merging the new person back undoes it. It's a 400 when they aren't heard in that asset, or
are heard only there (rename them instead). unlink {with} removes a link (404 when there's none). Linking, unlinking, "not the same" and undoing a merge need
editor access to both people's namespaces and are audited (speaker.link, speaker.unlink, speaker.not_same, speaker.detach,
speaker.merge.undo).
entities¶
GET /api/v1/entities
GET /api/v1/entities/types
GET /api/v1/entities/suggestions
GET /api/v1/entities/merges
GET /api/v1/entities/timeline
POST /api/v1/entities/retype
POST /api/v1/entities/merge
POST /api/v1/entities/merges/{mid}/undo
POST /api/v1/entities/not-same
GET /api/v1/entities/{eid}
GET /api/v1/wiki/{ns}/{address}
GET /api/v1/entities/{eid}/mentions
GET /api/v1/entities/{eid}/connections
POST /api/v1/entities/{eid}/rename
POST /api/v1/entities/{eid}/hide
POST /api/v1/entities/{eid}/link
DELETE /api/v1/entities/{eid}/link/{other}
POST /api/v1/mentions/{mention}/move
GET /api/v1/graph/explore
GET /api/v1/graph/path
An entity's page is its page in the web app, by name: /wiki/<namespace>/<Name_As_Written>.
GET /wiki/{ns}/{address} says which entity an address names: by its name in any case and spacing (underscores for
spaces), or a name it had before a rename or a merge. It answers {kind, id, namespace, title, path, canonical};
canonical is false when the address is another way of writing path (Notes).
dates¶
GET /api/v1/dates
The dates assets' lines give, as days on the calendar (Dates): start and end
(YYYY-MM-DD, or YYYY-MM and YYYY for their first and last day) keep those that fall between them, ns, recording
and q narrow them. Each item has day and until (its first and last day), period (day, weekend, week,
month, year), said (the words), relative (worked out from the asset's own date), and the line: recording_id,
title, idx, t0, time, speaker and text. reading says whether analysis reads dates now.
metadata¶
GET /api/v1/resources/{rid}/metadata
PUT /api/v1/resources/{rid}/metadata
GET /api/v1/resources/{rid}/metadata/history
POST /api/v1/metadata/edits/{eid}/revert
GET /api/v1/namespaces/{name}/metadata
PUT /api/v1/namespaces/{name}/metadata
POST /api/v1/metadata/bulk
fields¶
GET /api/v1/namespaces/{name}/fields
POST /api/v1/namespaces/{name}/fields
GET /api/v1/namespaces/{name}/fields/{fid}
PATCH /api/v1/namespaces/{name}/fields/{fid}
DELETE /api/v1/namespaces/{name}/fields/{fid}
GET /api/v1/resources/{rid}/fields
PUT /api/v1/resources/{rid}/fields
GET /api/v1/namespaces/{name}/collections/{cid}/fields
PUT /api/v1/namespaces/{name}/collections/{cid}/fields
GET /api/v1/resources/{rid}/files/{fid}/fields
PUT /api/v1/resources/{rid}/files/{fid}/fields
Custom metadata fields, defined by editors on a namespace or on one of its collections (collection). A field describes
the resources, the collections or the files inside where it's defined (target): one defined on a namespace applies
everywhere in it, one defined on a collection to what is inside that collection. Each has a label (unique where it's
defined, for its target), a type (text, longtext, number, date, boolean, choice, choices or link),
options for the two choice types, help, and whether it's published: published fields appear on public pages and
in IIIF metadata (Manifests for resources, Collections for collections), internal ones only in the workspace. New
fields are internal. Up to 100 fields per namespace.
Editors of the namespace define fields on it and on any of its collections; editors of a collection, on it. PATCH
renames a field and changes its options (400 for an option that items have chosen), help, publishing or place in the
order (ord); its type and target stay. DELETE deletes it with every value it has (uses, which GET of one field
also reports). These are audited as field.create, field.update and field.delete. A collection's fields go when it
is deleted.
Values: GET …/fields on a resource, a collection or a file lists the fields that describe it with its values;
PUT with {"values": {"<field id>": value}} sets them (null clears one; fields not named keep theirs). Values are
checked by type: text up to 500 characters (long text 5,000), a number, a date as YYYY, YYYY-MM or YYYY-MM-DD, true or
false, an option (or a list of options), an http(s) link; 400 says what doesn't fit, or names a field that doesn't
describe the item. Editors set a resource's and its files' values, those who arrange a collection (editors of the
namespace, admins of the collection) its own. A resource's values are part of its metadata history, so reverting an
edit puts them back too; saving values is audited as fields.save. A resource that moves keeps its values, and shows
those of the fields that describe it in its new place.
GET /resources?field=<id> lists the resources with a value for a field; with value, those whose value contains
that text (text and links, ignoring case), equals that number, starts with that date (1998, 1998-05), is true or
false, or is (or includes) that option. Saved views keep field and value.
video¶
GET /api/v1/resources/{rid}/media
GET /api/v1/resources/{rid}/frames/{name}
PATCH /api/v1/resources/{rid}/ocr/{span}
DELETE /api/v1/resources/{rid}/faces/{track}
GET /api/v1/namespaces/{name}/faces
GET /api/v1/namespaces/{name}/faces/unnamed
DELETE /api/v1/namespaces/{name}/faces
PUT /api/v1/namespaces/{name}/faces/mode
POST /api/v1/faces/merges/{mid}/undo
POST /api/v1/faces/{fid}
POST /api/v1/faces/{fid}/name
DELETE /api/v1/faces/{fid}
POST /api/v1/faces/{fid}/merge
POST /api/v1/faces/{fid}/speaker
POST /api/v1/faces/{fid}/dismiss
GET /api/v1/resources/{rid}/blur-faces
POST /api/v1/resources/{rid}/blur-faces
PUT /api/v1/resources/{rid}/blur-faces/visitors
PUT /namespaces/{name}/faces/mode {mode?, purpose?, pixelate?, reprocess?} (owners) sets the namespace's face mode
and whether the faces found are pixelated in the pictures visitors see (pixelate; needs faces detected; see
Video). GET /namespaces/{name}/faces says both, and the player's faces_mode and faces_pixelate
say them for a resource. GET /resources/{rid}/frames/{name} (frames, keyframes, face crops, a document's or an
image's pages and thumbnails) serves a request without a role in the namespace the faces found on it pixelated, where
the namespace says so; a role, or a link the API signed for a member (full=1), gets the picture as it is.
GET /namespaces/{name}/faces/unnamed?limit= lists the faces nobody has named yet (recognize mode; none otherwise),
the largest group first: in the most assets, then the longest on screen (a page counts five seconds). Each has a few
signed crops (thumbnails, from different assets first), the assets it's in (recordings, where it's seen longest
first), how often it's seen, and guess, a name to offer: the voice it's linked to, else a named face or voice
suggested for it. POST /faces/{fid}/name {name} (editors) names one: a name another face of the namespace has (any
case) joins it to that face, a merge whose id comes back as merge to undo with POST /faces/merges/{mid}/undo; any
other name becomes its own, undone with POST /faces/{fid} {"name": ""} (Naming faces).
POST /resources/{rid}/blur-faces {who?, tracks?} (the process permission) makes a copy of a video with faces
blurred for sharing, in the background: every face found (who all, the default), the ones nobody has named
(unnamed), or the face tracks in tracks (chosen); 202 with the job, 400 for anything but a video or with no faces
to blur. The copy is kept as one of the resource's files (an attachment labelled "Faces blurred"); asking again
replaces it. GET /resources/{rid}/blur-faces (members) gives {blurred, job}: the copy (file, name, size, a
signed download, faces, who, tracks, made_at, visitors) and the latest job making one (status, progress
in percent while ffmpeg runs, waits while quiet processing holds it, error). PUT .../blur-faces/visitors {on}
(owners) has visitors play the copy from /audio and /media (and IIIF and feeds) instead of the original
(Blurring faces for sharing).
Objects¶
The objects step finds the people, vehicles, animals and everyday things (the 80 kinds of the COCO dataset) on a
video's sampled frames and on a document's or an image's pages; it comes after faces in the standard pipeline. With
no detector on the server it's skipped, and its job says why (Configuration). Each kind
found is kept once per resource, and the player's objects lists them, the most seen first:
{label, spans, screen_ms, first_ms, count, score, frame, box, boxes, paged, engine}: where it's seen ([from, to)
in ms, or on a document's pages page numbers from 0, with paged), how long or on how many pages, how often it was
found, the detector's average confidence, a signed link to the frame or page it's best seen on and where it is there,
and each place it was found ([t, x, y, w, h, score], at most 500). The kinds a resource has are its objects, which
GET /resources?object= filters on; search finds them by name (source: object), and its object filter and facet
use them (search). Moving or deleting a resource moves or deletes its objects.
NSFW¶
The nsfw step scores a video's sampled frames, a document's pages or an image for NSFW pictures with a model on the
server, where NSFW classification is on in the resource's namespace (off by default); it comes after
objects in the standard pipeline, and is skipped, saying why, where it's off. A resource, its player and its Library
row carry nsfw: {flagged, score, manual, blur, display, threshold, checked, of, at}, and on the resource and its
player hits, its highest-scoring pictures ({t, score, frame}, a signed link); null where it's off in the
namespace or the resource wasn't classified. PUT /resources/{rid}/nsfw {flagged} (editors) marks it NSFW (true)
or not (false) by hand, over the model's score, or hands it back to the score (null); a 409 where it's off.
Audited as recording.nsfw. GET /resources/nsfw (ns for one namespace) says whether it's on in any namespace
you read, how many resources are flagged, and how many of those the Library leaves out (hidden). Search hits and
photo search results carry nsfw: true for the ones the web app blurs.
Enhancing images¶
The enhance step comes first in the standard pipeline: where enhancing images is on (off by
default), it measures an image and, when it's small, blurry, noisy, dull or has small faces, keeps an enhanced copy
that read and the steps after it work from; the original stays as it is. Other resources skip it. A resource
carries enhanced: {kind, low, measured, enhanced, engine, scale, width, height, forced, at, download} (download
a signed link to the copy), or null until the step looked at it. POST /resources/{rid}/enhance (the process
permission) enhances an image whatever the settings and its quality and runs its pipeline's later steps again,
answering the job; 400 for anything but an image. Audited as recording.enhance. GET /resources/{rid}/enhanced
is the copy, for members of its namespace.
Descriptions¶
The describe step has a model that can see images say in a few sentences what each page of a document or an image
shows, and each shot of a video (from its keyframe): the setting, the people (by what they do and wear, never who they
might be), the things and any text in it. The model is the one an admin chose as llm.vision_model on the LLM
provider, or, with llm.describe_engine florence-2 or moondream, Florence-2 or Moondream on the server itself,
which needs no provider. It
comes after nsfw in the standard pipeline, and describes at most llm.describe_max pages or shots of a resource.
Without an LLM, with no llm.vision_model, before Florence-2 or Moondream is fetched, or where the chosen Moondream
can't run, it's skipped, saying why; a model that
can't see makes it fail with what the server said (Configuration). The player's descriptions lists them in order:
{idx, t0, t1, text, model, frame, paged}: the shot or page, where it starts and ends (ms; on pages, the page from 0
and the next), what the model said, which model (such as Florence-2-base or Moondream-2), and a signed link to
the picture it was
shown. Search finds them
(source: described, search). Moving or deleting a resource moves or deletes them; the step run again
replaces them.
iiif¶
GET /api/v1/resources/{rid}/iiif
GET /api/v1/resources/{rid}/content-state
POST /api/v1/import/iiif/preview
POST /api/v1/import/iiif
Importing from IIIF (admins) reads a Presentation 3 Manifest, or a Collection's Manifests, also those of the
Collections inside it (at most 8 deep and 50 Collections read). The preview of a Collection lists its Manifests in
order (items, the first 200, each with path: the Collections it's in below this one), how many it found
(total), how many Collections it read (collections) and whether it stopped early (more). POST /import/iiif
{url, namespace, collection?, keep_transcripts, limit, wait} imports the first limit Manifests, all into
collection (default: the namespace's default collection).
jobs¶
GET /api/v1/jobs
POST /api/v1/jobs
GET /api/v1/jobs/{jid}
GET /api/v1/jobs/{jid}/log
POST /api/v1/jobs/steps/{step}
POST /api/v1/jobs/cancel
POST /api/v1/jobs/retry
POST /api/v1/jobs/{jid}/cancel
POST /api/v1/jobs/{jid}/retry
POST /api/v1/jobs/{jid}/move
GET /api/v1/workers
POST /api/v1/workers/{name}/pause
POST /api/v1/workers/{name}/drain
POST /api/v1/workers/{name}/resume
GET /api/v1/events
GET /workers (admins) lists every worker that has checked in: the steps it runs, its heartbeat_at (every 30 s,
every 15 s while it runs a job), current job, paused/draining (with paused_by, paused_at), its machine's
load (1-minute load average per CPU) and cpus, and steps_last_hour (steps it finished, done or skipped). pause
stops it taking new jobs; drain also hands the job it has back to the queue after the step it's on (it stays paused);
resume undoes either. Each returns the worker, is for admins, and is audited as worker.<action>.
GET /jobs lists the newest jobs (limit, up to 2,000) in the namespaces you can read, filtered by status
(comma separated), recording, batch (a batch run's jobs) and namespace (404 when you can't read it). counts
gives the jobs of each status and namespaces the jobs in each namespace, both over namespace and batch but any
status, so a client can show its filters' counts while one is applied.
Every job names its asset (title) and the pipeline it runs with the version it pinned (pipeline:
{id, version, name}; {name: "Standard"} for the standard steps, null when the steps were chosen directly).
GET /jobs/{jid} adds how each step went, step_runs (one per step, null until it runs): started_at, finished_at,
seconds, outcome (running, done, skipped, failed), note (its last message, why it skipped, or its error),
worker, log_from/log_to (its lines of the whole log) and outputs ([{key, template, version, model}], saved as
outputs.<key>). It also adds estimates, how long each step usually takes on this asset in seconds (from the
last 25 runs of that step or template; null with none), and while the job is queued or running eta_seconds.
GET /jobs?status=queued&order=queue lists queued jobs in the order workers take them, the next first (each job
carries its priority: the highest is taken first, among the jobs a worker can run). POST /jobs/{jid}/move moves a
queued job: {to: "top"} or {to: "bottom"}, or {before: <jid>} or {after: <jid>} another queued job you can
see. The queue is numbered again around it, so the others keep their order, and jobs queued later join the back. It
returns position (1 is next) and queued, both among the jobs you can see.
Bulk changes work in every namespace where you may process (or namespace), or on jobs (ids, each checked):
POST /jobs/cancel {jobs?, namespace?, status?} clears the queue, cancelling queued and held jobs; with running in
status, running jobs stop after the step they're on (stopping). POST /jobs/retry {jobs?, namespace?, status?}
retries the failed jobs (the dead letter queue; status: ["failed", "cancelled"] takes cancelled ones too), each
from the step it stopped at and at the back of the queue; a job whose asset was deleted, or whose asset already has a
job queued or running, is left alone (skipped). So POST /jobs/retry {jobs: <the ids cancel returned>, status:
["cancelled"]} undoes clearing the queue. Both return the ids they changed (jobs) and are audited as jobs.cancel
and jobs.retry. Retrying one job puts it at the back of the queue too.
A run keeps its whole log (up to 100,000 lines); the job itself carries its last 200 lines (log) and how many there
are (log_total). GET /jobs/{jid}/log?after=&limit= pages through it: {start, lines, total, more}, up to 5,000
lines a page (runs from before whole logs were kept have their last 200 lines).
GET /events streams job events: one per job change in the namespaces you can read, from since. With
logs=<jid> it follows that one job, and each batch of its new lines comes as a log event
{job, start, lines}: start numbers the first line (the first event carries up to the last 200), so a client can
tell an overlap from a gap and fetch the gap with GET /jobs/{jid}/log. Lines go out about every 2 seconds.
sources¶
GET /api/v1/sources/backends
GET /api/v1/sources
POST /api/v1/sources
PATCH /api/v1/sources/{sid}
DELETE /api/v1/sources/{sid}
POST /api/v1/sources/{sid}/test
GET /api/v1/sources/{sid}/browse
GET /api/v1/watches
POST /api/v1/watches
POST /api/v1/watches/preview
PATCH /api/v1/watches/{wid}
DELETE /api/v1/watches/{wid}
POST /api/v1/watches/{wid}/scan
GET /sources/{sid}/browse?path= lists a folder of a source; each file says which assets it is already
(imported: [{recording, namespace}]).
A watched folder's kinds says what it picks up: audio (and video), transcripts, both of those, documents
(documents and images) or all of them, the default. The three from before documents (audio, transcripts, both)
read PDFs, Word and text files as plain text, as they always did; documents and all take them as documents
(subtitles and JSON stay transcripts). POST /watches/preview
{source, path, kinds?, include?, exclude?} counts what a folder would pick up: files, audio, transcripts,
documents and images.
templates¶
GET /api/v1/templates
POST /api/v1/templates
POST /api/v1/templates/preview
GET /api/v1/templates/{tid}
PATCH /api/v1/templates/{tid}
POST /api/v1/templates/{tid}/versions
GET /api/v1/templates/{tid}/diff
PATCH {name?, description?} renames a template or changes its description (admins); its text changes by saving a
version.
pipelines¶
GET /api/v1/pipelines
POST /api/v1/pipelines
GET /api/v1/pipelines/{pid}
PATCH /api/v1/pipelines/{pid}
POST /api/v1/pipelines/{pid}/versions
POST /api/v1/pipelines/{pid}/run
PATCH {name?, description?} renames a pipeline or changes its description (admins); its steps change by saving a
version.
chats¶
GET /api/v1/chats
GET /api/v1/chats/capabilities
GET /api/v1/chats/tools
POST /api/v1/chats
GET /api/v1/chats/{cid}
PATCH /api/v1/chats/{cid}
POST /api/v1/chats/{cid}/title
DELETE /api/v1/chats/{cid}
POST /api/v1/chats/{cid}/messages
GET /api/v1/chats/{cid}/messages/{mid}
POST /api/v1/chats/{cid}/stop
POST /api/v1/chats/{cid}/messages/{mid}/check
PUT /api/v1/chats/{cid}/messages/{mid}/rating
GET /api/v1/approvals
GET /api/v1/approvals/count
GET /api/v1/approvals/{aid}
POST /api/v1/approvals/{aid}
GET /approvals lists what waits for your pick and what became of it, newest first: the assistant's proposals and
automations' actions (Approvals); filter by status, origin (automation or chat),
automation or chat_id, and page with before (the last id of the page before) and limit. /approvals/count
gives {waiting, automations}. POST /approvals/{aid} {decision, params?} decides one: approve, sample (a batch
run on 3 assets first), decline, edit (an automation's action with params changed) or always (approved, and
that node runs without asking from now on). Approving (and a sample), editing and always need a signed-in session,
for the assistant's proposals as for an automation's actions (403 for an API key or an app, which can only decline);
each approval's can says what you may do with it.
GET /chats/capabilities tells anyone signed in whether a language model is set up (configured), which (model),
whether it uses tools (tools, max_steps, max_tool_calls), whether answers can be checked (check) and the models people may pick
(the configured one first; llm.chat_models, else the server's list); never the model server's address or key. A
conversation's model (POST /chats, PATCH /chats/{cid}; null for the configured one) answers in it, and a
question can name another (POST /chats/{cid}/messages {content, model}, e.g. to retry); a model that isn't offered is
a 400. Each answer records the model that wrote it, and its meta what produced it; GET
/chats/{cid}/messages/{mid} gives one message with its record and its rows in the activity ledger
(What each answer records). POST /chats/{cid}/stop stops the answer being written in your conversation after the piece or tool
step it's on: the stream sends stopped, then done with the saved message, whose stopped is true and whose
content is what came before ((stopped) when nothing had). {stopping: false} when nothing was being written.
POST /chats/{cid}/messages {content, edit} edits one of your earlier questions (edit is its message id): it and
everything after it are replaced by this question and its new answer; 404 when edit isn't a question in that
conversation. A conversation Lens titled (title_by assistant) is titled again when that question is edited; one
you named (title_by person: POST /chats or PATCH /chats/{cid} with title) keeps its name. After a
conversation's first answer the model titles it, and the stream ends with title; POST /chats/{cid}/title has it
titled now from how it starts, {title, title_by} (409 before the first question, 400 without a model)
(Titles).
GET /chats/tools lists the tool groups and tools you have, each tool's effect (read, asks or out), why a
group isn't available and the setting that switches it on, the most tool calls a question may ask for
(max_tool_calls) and your latest choice (remembered); chat_id reads it for that conversation's scope. A
conversation's tools and max_tool_calls (POST /chats, PATCH /chats/{cid}; null for Auto) are what its answers
may use, and a question can set its own, with a scope for that answer alone (POST /chats/{cid}/messages {content,
tools, max_tool_calls, scope}) (Choosing tools, namespaces and a limit).
A conversation's scope narrows what it draws on: namespaces, recordings, collections, speakers, from and
to; every key narrows it further, and an empty scope is everything you can read. collections are ids of
collections you can see (yours or shared); the conversation draws on their assets as they are each time it
answers, so a filter collection's new assets count, and a deleted collection adds none.
GET /chats/{cid} returns each answer with the steps the assistant took ({tool, args, summary}), its notice and
error, and its latest source check ({claims, supported, verdicts, uncited}), as they were when it was written
or checked.
browser¶
POST /api/v1/browser/sessions
GET /api/v1/browser/sessions
DELETE /api/v1/browser/sessions/{sid}
GET /api/v1/browser/sessions/{sid}/actions
POST /api/v1/browser/actions/{aid}
POST /api/v1/browser/sessions/{sid}/run
A browser's Lens side panel connects a session and carries out the actions the assistant asks of it; a question sent
with browser (POST /chats/{cid}/messages {content, browser}) can use that browser while it's connected. See
The assistant in your browser.
collections¶
Saved collections: lists of assets from anywhere, to chat with or run batches on. A namespace's own collections, which assets live in, are under namespaces.
GET /api/v1/collections
POST /api/v1/collections
GET /api/v1/collections/{cid}
PATCH /api/v1/collections/{cid}
DELETE /api/v1/collections/{cid}
GET /api/v1/collections/{cid}/evidence.pdf
GET /api/v1/collections/{cid}/feed
POST /api/v1/collections/{cid}/feed/token
DELETE /api/v1/collections/{cid}/feed
GET /{cid}/evidence.pdf is the collection as one evidence PDF. GET /{cid}/feed is your private
podcast feed of it: its address (url, read by podcast apps with no session), how many episodes it lists (its
assets with audio or video that you can read) and when an app last read it, made the first time you ask;
POST /{cid}/feed/token gives it a new address and DELETE /{cid}/feed stops it
(A collection's podcast feed).
playlists¶
Clips of moments of any resources, kept in order to play back to back.
GET /api/v1/playlists
POST /api/v1/playlists
GET /api/v1/playlists/{pid}
PATCH /api/v1/playlists/{pid}
DELETE /api/v1/playlists/{pid}
POST /api/v1/playlists/{pid}/items
PATCH /api/v1/playlists/{pid}/items/{iid}
DELETE /api/v1/playlists/{pid}/items/{iid}
PUT /api/v1/playlists/{pid}/order
A playlist is its maker's: a name unique among theirs (up to 120 characters), a description, and shared. A
private one is theirs alone; a shared one is read by everyone signed in, and each reader gets only the clips of
resources they have a role on (in the resource's namespace, or on its collection), whoever made it. Only its maker
changes it. Up to 200 each. Sharing and unsharing are audited (playlist.share, playlist.unshare).
A clip is a moment of a resource you can read: POST /{pid}/items with the recording and t0 and t1 (ms from
the start; a document's pages have a reading-pace clock; without t1, or with one not after t0, it lasts 30
seconds, and it stops at the end of the resource), a title (up to 200 characters), a note (up to 2,000) and the
quote picked in the text, at position (from 0; default the end). With highlight it clips that
highlight: its resource, passage and label, unless given; the clip stays when the highlight goes.
Clips aren't highlights: anyone who can read a resource keeps a clip of it, and only for themselves, where an editor
makes a highlight for everyone. Up to 500 in a playlist. PATCH changes a clip's title, note (empty clears them) or
times; DELETE takes it out, and Undo adds it again with its position. PUT /{pid}/order with items, clip ids
in their new order, puts those first, as named, and the clips not named after them, as they were.
GET /playlists lists yours, the latest used first (the first is where a clip goes when none is named; adding,
changing, removing or reordering clips uses a playlist), then the ones others shared, each with count and
duration_ms of the clips you can read in it. GET /{pid} is everything a player needs: the clips in order, each
with its resource's recording_title, namespace and media kind, its signed audio link (the resource's audio or
video, as the recording player gets it; null when it has none, so play its text), its times and link, its page in
the web app at the clip's start. hidden counts the clips of your own playlist whose resources you can no longer
read. Clips go when their resource is deleted; private playlists go when their maker's account is removed, shared ones
stay. Agents use them through MCP (list_playlists, add_to_playlist); the web app's side is in
Playlists.
views¶
GET /api/v1/views
POST /api/v1/views
PATCH /api/v1/views/{vid}
DELETE /api/v1/views/{vid}
Saved views of the Library: a name (unique among your views, ignoring case), the namespace it shows (null: every
namespace you can read) and its state: the tab (all, attention, processing), the filter box q, statuses,
a speaker (a person, by name; each namespace has its own person ids), the Library's date and duration ranges (kept as
ranges, so 30d stays the last 30 days), media, tags, a collection of its namespace and sort (as
GET /resources takes it). A view is its
maker's; shared: true shows it to everyone with a role in its namespace (sharing needs editor access there, and a
view of every namespace can't be shared). GET lists yours, then the shared ones of namespaces you can read, each
with mine and can_delete; a view of a namespace you can no longer read is left out. Only its maker changes a view
(PATCH with name, shared or state); its maker, or an owner of its namespace for a shared one, deletes it. Up to
100 views each. Sharing, unsharing and deleting a shared view are audited (view.share, view.unshare,
view.delete).
searches¶
GET /api/v1/searches
POST /api/v1/searches
PATCH /api/v1/searches/{sid}
DELETE /api/v1/searches/{sid}
Saved searches: POST with a name (unique among yours), the search's q, and any of namespace, speaker,
emotion, event, recording, object, date_from and date_to (YYYY-MM-DD), tag, origin and language,
as GET /search takes them (one value each; date_from and date_to are its from and to). They follow the rules
of saved views (above): yours, or
shared with the namespace they search (editors there; a search of every namespace can't be shared); only their maker
renames or shares them (PATCH with name or shared); their maker, or an owner of the namespace for a shared one,
deletes them. GET lists yours, then the shared ones, with speaker_name and recording_title where you can read
them. Sharing, unsharing and deleting a shared one are audited (search.share, search.unshare, search.delete).
Saved searches are not collections: to chat with a search or run things on it, keep it as a collection too.
batches¶
POST /api/v1/batches/estimate
GET /api/v1/batches
POST /api/v1/batches
GET /api/v1/batches/{bid}
POST /api/v1/batches/{bid}/combine
POST /api/v1/batches/{bid}/{action}
GET /api/v1/batches/{bid}/results
GET /api/v1/batches/{bid}/results.{fmt}
public¶
What visitors see (Access). No sign-in is needed; send a token and people with permission see more, and so do visitors whose address is in an IP group.
GET /api/v1/public/home
GET /api/v1/public/collections/{name}
GET /api/v1/public/search
GET /api/v1/public/recordings/{rid}
POST /api/v1/public/recordings/{rid}/request
GET /api/v1/access-requests
GET /public/home lists the featured public assets (for everyone, members too) and the collections the caller
sees anything in, with how many of their assets they see (network names the IP group that opens all of one to
the caller's address). GET /public/collections/{name} is a collection's page:
the namespace's description and the assets the caller sees there, newest first (limit, offset, and total on
all pages). Each asset is a card with the caller's view of it; locked cards carry the title only. A poster
frame comes only with media the caller may play. A collection with nothing for the caller answers 404.
GET /public/search?q= (limit, offset) finds the assets the caller sees by their title, and by the lines of
text they may read (the same query syntax as /search: words, "phrases", OR). Title matches come first;
each result is a card with up to three matching hits (t0 and an HTML-escaped snippet with <mark>, and the
page of a document's text). Restricted assets and closed text match on the title only.
GET /public/recordings/{rid} is an asset's public page as the caller may see it. view says how:
full: the caller has permission, so all of it: a role in its namespace (admins have every role;memberis true), permission given on the asset (granted), or an address in an IP group that opens it (network, the group's name).public: a public asset, for everyone else: its description (the metadata IIIF publishes) and only its open parts:media(a signed link to the audio or video, with its waveform),transcript(people and lines, and the transcript files to download when the text is open to everyone) andchapters(the index).locked: a restricted asset, for someone signed in without permission: its title and namespace only.
A document's or an image's media are its pages and its file: media.kind is document or image, media.pages
lists each page (idx, width, height, label, and signed links to its image and thumb), media.url is the
file to save and media.poster the first page. Its transcript is its text: each segment has its page (p), and the
downloads are its text (txt, md, json) without subtitles. A public document's page opens at ?page=<n> (from 1).
Parts the caller can't use are null and listed in closed. files are the supplementary files the caller may
download (with signed links: those that follow an open part, or all of them with permission), and files_closed how
many more there are. For someone signed in without permission,
can_request says whether they may ask for access (something is closed to them) and request is their latest request.
POST …/request with an optional message asks the owners (400 when the caller already sees all of it, or when all of
it is open to everyone); asking again replaces the request. GET /access-requests lists the requests waiting for an
answer in the namespaces the caller owns. Assets the caller may not see at all (restricted ones
to visitors who aren't signed in, private ones to anyone without permission) answer 404, as missing ones do.