Skip to content

Configuration

Lens has three layers of configuration, each for a different kind of setting.

Layer Where What
Environment fastapi_backend/.env, container env secrets, database connection, CORS, mail, where archive.yaml is
archive.yaml $ARCHIVE_CONFIG (see archive.example.yaml) namespaces and their folders, processing defaults, bootstrap-only paths
Settings in the app GET/PUT /api/v1/settings/<section> (admins), stored in SurrealDB everything that can change at runtime

Precedence for processing settings: built-in defaults (app/domain/store.py, DEFAULTS), then archive.yaml, then the app, then the few environment overrides below. Secrets saved in the app (such as an LLM API key) are encrypted with AES-GCM and are write-only: the API reports whether one is set, never its value.

Backend environment

Variable Default
ACCESS_SECRET_KEY required signs access tokens and media links (openssl rand -hex 32)
ACCESS_TOKEN_EXPIRE_SECONDS 900 access token lifetime
MEDIA_URL_EXPIRE_SECONDS 21600 signed media link lifetime
PASSWORD_RESET_EXPIRE_MINUTES 60
SURREAL_URL embedded ws://host:8000 for a server; see Database
SURREAL_USER / SURREAL_PASS / SURREAL_NS / SURREAL_DB / SURREAL_POOL_SIZE root / root / archive / main / 8
SURREAL_AUTH root database: SURREAL_USER is a user of that one database, not the server's root (a worker node signs in so)
ARCHIVE_SECRET_KEY generated in data_dir/secret.key encrypts stored credentials; keep it stable and backed up
ARCHIVE_CONFIG archive.yaml processing configuration file
ARCHIVE_ALLOWED_HOSTS break-glass override of allowed Host headers if a bad setting locks everyone out
RUN_BACKGROUND follows workers.inline run job workers and folder watching inside the API process
LENS_SETUP_CODE random fix the first-run setup code (automation)
LENS_ADMIN_EMAIL / LENS_ADMIN_NAME create the first admin at startup, with no setup code; the log prints a sign-in link for adding their passkey (First-run setup)
LENS_ADMIN_PASSWORD give that admin a password instead (passwords stay on)
LENS_NAMESPACE create the first namespace at startup, while there is none
LENS_LLM_BASE_URL / LENS_LLM_MODEL / LENS_LLM_API_KEY / LENS_LLM_VISION_MODEL the model provider; wins over Settings, which show these locked
LENS_SETUP_SETTINGS settings to start with, as JSON {"section": {"key": value}} (or that JSON base64url-encoded): saved once at startup as if made in the app, so unlike the variables above they stay editable there. The web installer writes its answers here; a budget section (hard_usd, soft_usd, period, on_over, price_in, price_out) sets the overall budget
LENS_SETUP_WIZARD off: never show the setup wizard
LENS_TELEMETRY / LENS_TELEMETRY_ENDPOINT / LENS_TELEMETRY_HEADERS off opt-in OpenTelemetry traces and metrics, sent only to this OTLP/HTTP endpoint (Telemetry); LENS_TELEMETRY=off keeps it off whatever Settings say
LENS_ANALYTICS / LENS_GA_MEASUREMENT_ID / LENS_GA_API_SECRET off opt-in usage events sent to your own Google Analytics 4 property (Usage analytics)
LENS_TARGET / LENS_BIND how install.sh set the machine up: the edition (full or lean) and where the web app's port is published (0.0.0.0: the network, 127.0.0.1: this machine only). docker-compose.prod.yml and a microsandbox pass them on from .env, and Settings show them; unset, Lens says it doesn't know. They're changed in Settings, which has the install run again (Updates)
LENS_LOG_LEVEL / LENS_LOG_FORMAT info / text the log level and console format for the API and every worker (Logging); win over Settings → Logs
LENS_DOCS_DIR the docs/ folder next to the code where Lens's docs are, which the assistant and agents answer questions about Lens from (lens_help). The Docker Compose files mount the install's docs/ read-only at /docs, where Lens looks by default
FRONTEND_URL http://localhost:3000 the web app's address as people use it: links in emails, and where apps send people to sign in (OAuth)
CORS_ORIGINS ["http://localhost:3000"] origins allowed to call the API from a browser
OPENAPI_URL /openapi.json "" disables /docs and the schema
MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_FROM, MAIL_STARTTLS, MAIL_SSL_TLS, USE_CREDENTIALS, VALIDATE_CERTS SMTP for password reset and for telling owners about requests for access. Usually set in the app instead (Settings → Email, with Send a test email); set here, they win and show locked there. Without a server the links are logged

First-run setup

A fresh install (no accounts when the API first starts) walks its first admin through setup in the web app, asking only what has no answer yet and no default:

  1. Admin account, with the one-time setup code from the log (make setup-code), so a stranger who finds a new public server can't claim it. The admin signs in with a passkey (Authentication). Skipped when LENS_ADMIN_EMAIL creates the admin at startup; then open the sign-in link the log prints (or sign in with LENS_ADMIN_PASSWORD, if you set one).
  2. Model provider, while there is none: an OpenAI-compatible server (OpenAI, Ollama, llama.cpp, LM Studio, vLLM), its model and key, with a test. Servers running nearby and cloud providers are a click.
  3. Budget, for a paid (cloud) model with none (Budgets); a model on this machine or its network costs nothing, so it isn't asked.

When the model is set already, by the web installer (which asks for the budget with it) or by .env, there is nothing left to ask: the wizard finishes at startup and the first admin goes straight to Lens. These have defaults, so the wizard doesn't ask them; each is a click away there, and in Settings:

  • Namespace: the first one, with its knowledge graph shared or isolated; archive (shared) unless chosen. Already there when archive.yaml or LENS_NAMESPACE names namespaces, or an install script made one.
  • Storage: where data lives (shown; set in archive.yaml and SURREAL_URL), where Lens keeps its own files (this machine or a storage connection, Storage), the largest upload, and optionally a folder inside sources.local_roots to watch.
  • Apps and AI: whether apps and AI assistants (Claude, ChatGPT, Cursor and other MCP clients) may sign people in with their Lens account (OAuth), on unless turned off, how long their tokens last, and the MCP server's address to add to an assistant.
  • Telemetry: off unless chosen (Telemetry), and Google Analytics usage events, off unless chosen (Usage analytics).

Every step can be skipped, and the whole wizard too; all of it stays in Settings. Values from the environment win and show locked. Installs that already had accounts never see the wizard. The API side is GET /api/v1/setup, POST /api/v1/setup/namespace, PUT /api/v1/setup/llm, GET /api/v1/setup/llm/detect (model servers running on this machine or the Docker host, with their models), GET /api/v1/setup/llm/providers (the cloud providers), PUT /api/v1/setup/storage, PUT /api/v1/setup/oauth, PUT /api/v1/setup/telemetry and POST /api/v1/setup/finish (admins); GET /api/v1/setup says what is still to ask (todo: llm, budget), and GET /api/v1/auth/status whether the wizard is still pending (wizard_pending).

Frontend environment

Variable
API_BASE_URL where the Next.js server reaches the API (http://localhost:8000, http://backend:8000 in Docker)
AUTH_SECRET encrypts the NextAuth session cookie (npx auth secret)
AUTH_URL pins sign-in to one public URL of the web app. Leave it unset (the Docker Compose files do) so sign-in follows the address the browser is on: its LAN name, https:// address or Cloudflare tunnel
AUTH_TRUST_HOST true behind a proxy or in Docker
TRUST_PROXY_HEADERS true when a reverse proxy in front of the web app sets X-Forwarded-Host, -Proto and -For: they're passed on to the API, which names that address in OAuth discovery and reads the visitor's address for IP groups and throttles. Off, the web app reports the Host the browser sent and the address it was reached from
LENS_WEB_PROXY_HOSTS hosts whose X-Forwarded-For the web app passes on with TRUST_PROXY_HEADERS off (backend,worker in the Docker Compose files, for the Cloudflare tunnel)
LENS_S3_URL, LENS_WEBDAV_URL where the web app finds the file shares it relays at /s3 and /dav once Settings › File shares allows it (http://worker:9555 and http://worker:9556 in the Docker Compose files; unset, this machine's ports 9555 and 9556). See The graph as files
LENS_MQTT_WS_URL where the web app finds the sensor hub's MQTT over WebSockets, which it relays at /mqtt once Settings › Sensors allows it (http://worker:9883 in the Docker Compose files; unset, this machine's port 9883). See Sensors
LENS_HTTPS, LENS_HTTPS_PORT, LENS_HTTP_PORT, LENS_HTTPS_BIND install.sh: LENS_HTTPS=1 publishes the HTTPS and HTTP ports (443 and 80 by default) for HTTPS
LENS_HTTPS_LISTEN 1 in the process that has the HTTPS ports and runs Caddy (the worker in docker-compose.https.yml); LENS_CADDY: a caddy to use instead of fetching one

archive.yaml

Start from fastapi_backend/archive.example.yaml, which documents every key. The parts that can only be set there:

  • namespaces: folder trees scanned by lens scan, and whether each joins the shared knowledge graph.
  • data_dir: reports, frames, model caches, locks and (embedded mode) the database.
  • sources.rclone and sources.local_roots: which binary is run for remote storage and which local folders may be watched. These are bootstrap-only on purpose, so the web app can't choose what runs or open up the server's disk.
  • video.yunet_model / video.sface_model: face model files.
  • video.yolox_model / video.ultralytics_model: the object detector's model (Objects).

Settings in the app

Admins can change, in Settings: transcription, speaker separation, speech providers, voice IDs, analysis, the LLM provider and key, a local model, the AI assistant, the code sandbox, search (word search and search by meaning), reports and graph, maps, video, OCR, faces and objects, NSFW classification, enhancing images, workers, sign-in, components (and the edition, full or lean), access and embedding (whether other devices on the network reach Lens, embed frame ancestors, text and transcript upload limit, allowed hosts, trusted proxies, session length), remote access, email, chat rooms, notifications, automations, telemetry, the Fedora repository, sensors, file shares, uploads, storage, documents, API keys, secrets (a password manager that secret fields can refer to), and IIIF and metadata. The API refuses an allowed-host list that leaves out the address you are using.

The sections are in groups: AI and search, Speech and voices, Media and documents, Chat and email, Security and access, Files and devices, and System. Find a setting at the top of the list (or /) finds a section by its name or what it's for, and a single setting by its label, hint or choices: Enter opens the first, and a setting found by its label opens with its field in focus and its value selected, so typing replaces it. ⌘K (Ctrl+K) finds settings the same way, beside recordings and pages. In a section, the options most people leave as they are (tuning numbers, file types, the assistant's tool list, a provider not yet set up) are folded under a line that names them; a fold opens by itself when one of its fields has an error, and Find opens it on the field it found. Agents reach the same settings through the API (GET/PUT /api/v1/settings/<section>), and the admin assistant through read_settings and change_settings (Assistant).

Searching by the words goes through the index of search.engine (Settings → Search → Word search; how each works is in Database):

Setting Default
search.engine sqlite sqlite: a SQLite FTS5 file per database in data_dir/search/. surrealdb: SurrealDB's own full-text index, which needs several times the memory. opensearch: an OpenSearch (or Elasticsearch-compatible) cluster
search.opensearch_url empty the cluster's address, such as http://search.lan:9200; with opensearch and no address, the built-in index is used
search.opensearch_user / opensearch_password empty / none for a cluster that asks for them; the password is stored encrypted
search.opensearch_verify true whether its certificate is checked; turn it off only for a self-signed one on your own network
search.stemming english none for archives that aren't in English

After changing the engine or stemming, run lens reindex (or Settings → Search → Reindex now).

Search by meaning

Passages of assets' text and descriptions are embedded by an OpenAI-compatible embeddings server (POST /embeddings), so search can find moments by meaning (how it works). By default it asks the LLM provider's server (llm.base_url, with its key) for nomic-embed-text, a small model that runs offline in Ollama. When that server is Ollama, Lens pulls the model on it the first time it's needed (Components), so there is nothing to run; set embeddings.base_url to use another server, such as Ollama next to LM Studio, or llama.cpp's llama-server --embeddings. Without a server, search goes by the words as before.

embeddings:
  base_url: http://localhost:11434/v1   # null: the LLM provider's
  model: nomic-embed-text
Setting Default
embeddings.enabled true off: no passages are embedded, and searches go by the words
embeddings.base_url null the embeddings server; null: the LLM provider's (and its key). LENS_EMBED_BASE_URL sets it
embeddings.model nomic-embed-text the embedding model (LENS_EMBED_MODEL); e.g. mxbai-embed-large, bge-m3, all-minilm, text-embedding-3-small
embeddings.api_key / api_key_env none a key for the embeddings server, stored encrypted (LENS_EMBED_API_KEY), or the variable that holds it
embeddings.min_similarity null how alike (cosine, 0–1) a passage must be to count; null: what suits the model (0.52 for nomic-embed-text)
embeddings.neighbours 40 passages found per search, 5–500 (more when a page of results needs them)
embeddings.passage_chars 800 how long passages are, 200–4000 characters
embeddings.batch_size 32 passages sent per request, 1–256
embeddings.timeout 60 seconds per request (a search waits at most 15)
embeddings.query_prefix / document_prefix null what searches and passages start with; null: what the model wants (search_query: and search_document: for nomic-embed-text, an instruction for mxbai and bge, query: / passage: for e5)

Changing the model drops the stored vectors (another model's can't be compared) until assets are indexed again. When the server fails to index (it's down, or doesn't have the model), indexing jobs skip for ten minutes rather than each waiting on it, and the hourly routine waits too; Test in Settings → Search, Index now, or lens embed try again at once.

A busy local model server

One local server serving the chat model, a vision model and embeddings (an Ollama box, say) unloads one to load the next, and while it does it may hang up on a request. Lens asks again once, two seconds later, when the server drops the connection; a server that refuses the request, can't be reached or runs past llm.timeout (300 seconds) isn't asked again, and the error says which it was. A reasoning model (Qwen 3, DeepSeek R1) asked for something short, such as a conversation's title, can spend the whole length thinking and say nothing; Lens then asks it again without the limit. Ollama keeps more models loaded at once with OLLAMA_MAX_LOADED_MODELS on the Ollama machine.

Cloud model providers

The setup wizard's model provider step and Settings → LLM provider offer the model servers running nearby (Ollama, LM Studio, llama.cpp, vLLM, LocalAI) and these cloud providers, each through its OpenAI-compatible API. Picking one fills in the address and a model, and a cloud provider's API key field takes the focus, with a link to where the provider hands out keys: paste the key and save. The model stays editable, with the provider's other models suggested. Test tries the address, model and key on screen, saved or not; the saved key goes only to the saved address, so a new address is tested with the key typed for it. Over the API, POST /api/v1/settings/llm/test takes the same base_url, model and api_key, and with no body tests what is saved.

Provider Address Model filled in
OpenAI https://api.openai.com/v1 gpt-5-mini
Anthropic https://api.anthropic.com/v1 claude-opus-5-5
Google Gemini https://generativelanguage.googleapis.com/v1beta/openai gemini-flash-latest
OpenRouter https://openrouter.ai/api/v1 openrouter/auto
Groq https://api.groq.com/openai/v1 llama-3.3-70b-versatile
Mistral https://api.mistral.ai/v1 mistral-small-latest
DeepSeek https://api.deepseek.com/v1 deepseek-chat

The list is GET /api/v1/setup/llm/providers (admins), and the assistant's find_model_servers tool returns it with the servers it finds, so an agent setting Lens up uses the same addresses. Any other OpenAI-compatible service works by typing its address. An Amazon SageMaker endpoint serving a model with vLLM works too: its address is https://runtime.sagemaker.<region>.amazonaws.com/endpoints/<name> and its key an AWS access key, <access key id>:<secret access key>, which Lens signs each call with and never sends (Auto scaling makes such endpoints). So do Amazon Bedrock's models: the address is https://bedrock-runtime.<region>.amazonaws.com, the model a Bedrock model id, inference profile (us.amazon.nova-lite-v1:0) or provisioned throughput ARN, and the key an AWS access key the same way. Lens sends its requests to Bedrock's Converse API, with the system prompt, images, tools and tool results it carries, asks for JSON in the system prompt; a chat's answer streams as it is written (ConverseStream). Embeddings and speech need a server of their own then: they never borrow the AWS key. The keys need bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream on the model. A SageMaker endpoint's chat answers stream the same way (InvokeEndpointWithResponseStream), as the container writes them. Keys allowed only bedrock:InvokeModel or sagemaker:InvokeEndpoint still work: their answers come whole. Picking a provider changes nothing until you save; from then on, what Lens asks its model (summaries, chat, descriptions, with the text they need) goes to that provider.

llm.spread sends those calls to more addresses in turn: {"base_url": ..., "urls": [...]}, the provider's address and others serving the same model with the same key. It is used only while llm.base_url is that base_url, and auto scaling keeps it while a group's units serve (Auto scaling).

llm.tls_ca is the one certificate authority (PEM) the provider's certificate must be signed by, whatever name the certificate carries; the usual public authorities are then not trusted for it. Auto scaling sets it while a Lambda model unit serves (its group's own authority, Auto scaling); search by meaning and the voice don't borrow such a provider.

Chat models

People can choose the model a conversation uses, and ask a question again with another (Retry with another model). llm.chat_models (Settings → LLM provider) lists the models they may pick, besides llm.model, which is always offered and stays the default. Left empty, they may pick whatever the model server lists (GET /models, kept for a minute); on providers that list many models, or charge by model, list the ones you want offered.

Whether the assistant searches the web and reads pages (Web search and reading pages). Settings → AI assistant → Web search; the setup chat always asks before it changes these:

Setting Default
web.search off the search provider: off, duckduckgo (no key; fetches the ddgs client, about 30 MB, when picked), searxng (your own instance), brave or tavily (an API key)
web.searxng_url null your SearXNG instance's address, such as http://searxng:8080, with its JSON output on; asked directly, so it may be on your network
web.api_key none the Brave Search or Tavily API key, stored encrypted
web.results 5 results per search when the model doesn't say, 1–20
web.read_chars 20000 the most of a page's text the model reads at once, 1,000–200,000 characters
web.render false load a page that comes back nearly empty in Chromium, scripts run, and read it again (needs Chromium: the full edition)

Descriptions

The describe step (API) shows each page of a document or an image, and each shot's keyframe of a video, to a model that can see images, and keeps what it says each shows, for search and for people who can't see them. Settings → LLM provider → Describing pictures chooses the model (llm.describe_engine):

  • The provider's model that can see images (llm, the default). Each picture (at most 1024 pixels on a side) is a request to the LLM provider's server, with a model you choose as one that can see images: OpenAI's, or a vision model on LM Studio, Ollama or vLLM (LLaVA, Qwen-VL, Llama 3.2 Vision, Gemma 3 …). Lens can't tell which models see, so none is chosen until you choose one.
  • Florence-2 on this server (florence-2). Microsoft's Florence-2 (MIT), fine-tuned, describes each picture in a paragraph on the worker's CPU, through ONNX Runtime: no picture leaves the machine, no provider is needed and nothing is billed. Its 8-bit ONNX export is fetched the first time a job waits to describe something (Components), pinned to a revision and checked against its SHA-256: base (0.23B parameters, about 280 MB; a few seconds a picture on a laptop's CPU, longer on a Raspberry Pi) or large (0.77B, about 830 MB, more detail and about three times slower). It says what it sees in its own words: it reads no instructions, so it isn't told what the picture is, and it describes, rather than transcribes, the text in it (the OCR step reads that).
  • Moondream on this server (moondream). M87 Labs' Moondream captions each picture on the worker, through Hugging Face transformers and PyTorch: no picture leaves the machine and no provider is needed. Which one is llm.moondream_model:

    • moondream3-preview, Moondream 3 (Preview): 9B parameters (2B active), about 19 GB. It needs an NVIDIA GPU with 20 GB or more, or a Mac with 32 GB of memory: not a Raspberry Pi. Its weights and code are under the Business Source License 1.1 (production use is allowed, except offering it to others in competition with M87 Labs' own), which isn't an open-source licence, so Lens fetches it only when you pick it.
    • moondream2, Moondream 2 (Apache-2.0): 1.9B parameters, about 3.7 GB. It runs on a CPU with about 10 GB of memory, slowly: minutes a picture on four cores, against seconds for Florence-2.

    PyTorch (its CPU build without an NVIDIA GPU), transformers and the weights are fetched the first time a job waits to describe something (Components), the weights and the model's own code pinned to a revision; where the chosen one can't run, nothing is fetched and the step says why. Moondream 3.1 runs only on M87 Labs' Photon engine, whose kernels are licensed only under a separate agreement with them, so Lens doesn't offer it.

Each page or shot is a call to the model, so a long document costs as many: llm.describe_max caps how many of a resource are described. Each call is in the activity ledger (model.chat for the provider's model, model.describe for Florence-2 and Moondream, with the time it took and, for Florence-2, the tokens read and written).

Setting Default
llm.describe_engine llm what describes pages and shots: llm (the provider's model below), or on this server florence-2 or moondream
llm.vision_model none the provider's model that describes pages and shots; none (with llm): the describe step is skipped
llm.florence_model base Florence-2's size: base or large
llm.moondream_model moondream3-preview which Moondream: moondream3-preview (a GPU) or moondream2 (a CPU will do)
llm.describe_max 50 pages or shots of a resource described at most, 1–1000 (in order: the rest aren't)

Agents read and change these with the MCP tools picture_descriptions and set_picture_descriptions (admins).

Maps

The Library's Map shows where photos and videos were taken (Where an asset was taken) on tiles that each viewer's browser fetches itself. Settings → Maps:

Setting Default
maps.tile_url https://tile.openstreetmap.org/{z}/{x}/{y}.png where the tiles come from: an http(s) address with {z}, {x} and {y} (and {s} for a subdomain, if the server has them), with no password in it. Empty draws no tiles, only the dots
maps.attribution © OpenStreetMap contributors the credit the tiles' terms ask to be shown on the map, one line

The tile server sees which tiles are asked for, so the area each person looks at (around where their photos were taken) and the address they look from, as any web map's does; nothing else about the assets goes to it. To keep that on your own hardware, run a tile server of your own and give its address, or leave the address empty. OpenStreetMap's tiles are for light use under its tile usage policy; a busy install should use its own or a provider's.

Uploads

Audio, video, documents (PDF, Office and OpenDocument files, text, Markdown, saved web pages and emails) and images uploaded in the web app (Import → Upload) go up in pieces (API). Settings → Uploads:

Setting Default
uploads.max_mb 4096 the largest file, in MB
uploads.extensions the types folder scans import, documents and images which types can be uploaded: any of .m4a .mp3 .wav .flac .ogg .opus .aac .amr .aif .aiff .wma .mp4 .m4v .mov .mkv .webm .avi .mpg .mpeg .3gp .pdf .doc .docx .odt .rtf .ppt .pptx .odp .xls .xlsx .ods .txt .text .md .markdown .mdx .html .htm .eml .msg .jpg .jpeg .png .tif .tiff .webp .gif .bmp. A list saved before documents and images came leaves them out until they're added
uploads.chunk_mb 8 how much the web app sends per request, 1–64. Keep it below the request-body limit of any proxy in front of the web app (nginx's client_max_body_size is 1 MB unless set)
uploads.expire_hours 24 how long an unfinished upload waits for its next piece before what arrived is deleted, 1–720

Pieces are written straight to data_dir/uploads/.partial, never held in memory; finished files are kept in data_dir/uploads/<namespace>/, so give data_dir room for them (an upload is refused when it would leave less than 512 MB free). Text and transcript imports have their own limit, server.max_upload_mb (Settings → Access & embedding), which also caps a resource's supplementary files (API), kept in data_dir/files/<resource>/; watched folders have none.

Documents and images

A PDF uploaded as a document, or an image, has its pages drawn and read when its pipeline runs (API). Drawing pages and reading their text needs poppler-utils (pdftoppm, pdftotext, pdfinfo; in the Docker image); without it a PDF's text is read by pypdf and it has no pages to look at. Pages without text are read by the OCR engine of video.ocr_engine in the languages of video.ocr_languages (Tesseract is in the Docker image); without one, scans and images have no text.

Other documents are made into PDFs first (API): Word, PowerPoint and spreadsheet files, OpenDocument and RTF by LibreOffice; text, Markdown, saved web pages (HTML) and emails by Chromium printing a page Lens makes of them, or by LibreOffice where there's no Chromium. Outlook .msg emails also need the msg extra (pip install -e ".[msg]"; extract-msg is GPL-3.0). The lens:full Docker image has LibreOffice and Chromium (Deployment), and a microsandbox's full edition installs each the first time it's needed; without them, PDFs and images are read as before, and files that can be read as plain text (Word, text, Markdown) are imported as text. Settings → Documents:

Setting Default
documents.page_pixels 2000 the longest side of a page's image, in pixels, 800–6000
documents.thumb_pixels 360 the longest side of its thumbnail, 120–800
documents.ocr_below_chars 25 a page with fewer characters of text than this is read by OCR, 0–5000 (0: never)
documents.max_pages 2000 the most pages of one document that are drawn and read, 1–50000
documents.convert_seconds 300 how long making one PDF may take before its job fails, 10–3600 seconds
documents.attachment_resources true whether an email's attachments that Lens can read (documents, images, audio, video, emails) also become resources of their own; they're kept as its files either way
documents.video_urls false whether videos and podcasts can be imported by their address (API): their media is downloaded with yt-dlp, which Lens fetches on first use
documents.web_networks [] private networks (CIDR, such as 192.168.1.0/24) that web pages, calendar and news feeds and videos may be fetched from besides the public internet: for an intranet, or a calendar server at home. The web app can't list this machine (loopback) or link-local addresses, where clouds answer with the server's own credentials; archive.yaml or LENS_WEB_NETWORKS in .env (comma-separated, added to archive.yaml's) can. Those stay whatever Settings saves: the networks listed in Settings are added to them
documents.converter auto who makes the PDFs: auto uses anytopdf only for what LibreOffice and Chromium can't do here, anytopdf uses it for every document and image (and fetches it), lens never uses it; a pipeline's Read step can choose its own (processing)
documents.anytopdf_url none a conversion node: another machine running anytopdf queue serve and anytopdf queue work, such as https://convert.home:8640. Set, it converts instead of the program here
documents.anytopdf_token none the node's bearer token (its ANYTOPDF_QUEUE_TOKEN), kept encrypted
documents.tika_url none (off) an Apache Tika server, such as http://tika:9998 for the compose files' tika service
documents.tika_text true with a Tika server: documents nothing here can make into a PDF are read by Tika, as text without pages
documents.tika_metadata true with a Tika server: what every document says about itself (title, authors, language, dates) is read by Tika

Set at startup only (the config file; the web app can't choose what the server runs):

Setting Default
documents.soffice soffice or libreoffice on PATH LibreOffice
documents.chromium the first of chromium, chromium-browser, google-chrome, google-chrome-stable, chrome on PATH Chromium or Chrome (a headless shell works too); it also captures web pages (API)
documents.anytopdf the one Lens downloaded, else anytopdf on PATH the anytopdf program

Apache Tika

Apache Tika (Apache-2.0) reads the text and the description of about a thousand kinds of file. Lens can use a Tika server; it's off until documents.tika_url is set. The compose files have one ready, as an opt-in profile (about 250 MB of memory, so leave it off on a small Raspberry Pi):

docker compose -f docker-compose.prod.yml --profile tika up -d

(or add COMPOSE_PROFILES=tika to Lens's .env and run the install command again). Then, in Settings → Documents, set the Tika server to http://tika:9998 and press Test. A Tika server elsewhere works the same way.

  • Reading what nothing else can (documents.tika_text). When neither LibreOffice, Chromium nor anytopdf can make a document into a PDF here (a Word file on a lean server, say), or one of them fails on it, Tika reads it instead. Uploads and folder scans then take every document type, Office files included. Such a document has its text, which is searched, analyzed and asked about like any other's, but no pages to look at; its engine is text:tika. An email or an archive read this way gives its text, including its attachments' and files', but they aren't kept as files or resources of their own.
  • What a document says about itself (documents.tika_metadata). Every document is also described by Tika, as Dublin Core terms (GET /resources/{id} → described: title, creators, subject, description, publisher, language, created, modified, pages, words, format). Its title and creation date become the resource's while it's still named and dated after its file (an email's own subject and date come first), and its authors and language are its metadata's until someone saves others. If Tika can't describe it, the document is read anyway.

The document goes only to the address set: http(s) with no user name or password in it, no redirect followed, and sent as document.<type>, never by its own name. Tika has no sign-in of its own, so the compose service is reachable only inside Lens's network; put one elsewhere behind TLS on a network you trust, since every document is sent to it. Its answers are bounded (64 MB) before Lens reads them. The compose service runs read-only, as a user without privileges, with TIKA_HEAP (default 512m) as its Java heap and TIKA_VERSION (default 4.0.0-1) as its image.

anytopdf

anytopdf (MIT or Apache-2.0) is Lens's sister project: one static program that makes documents, photos and media into searchable PDFs. Lens uses it as a converter, two ways:

  • Here. With documents.converter set to anytopdf, Lens downloads release 0.4.0 for this machine on first use (Linux x86-64 and arm64, macOS), checks it against the release's checksum and keeps only the program and its face and object plugins in data_dir/models/anytopdf-0.4.0/. It makes the PDF of text, Markdown, web pages and emails from the same cleaned page Lens would print with Chromium, so a server without Chromium reads them, and it reads images too: a photographed page is found, straightened and flattened before OCR. Office files still need LibreOffice beside it. Where Chromium and poppler's pdftoppm are installed too, it draws each web page and email as it looks (--html-render, offline) and keeps the page's text as the search layer; without them (a Raspberry Pi, the lean image) it reads the text alone. On a conversion node, its own options decide (anytopdf queue serve … -- --html-render).
  • On a conversion node. With documents.anytopdf_url and its token set, every conversion goes to that machine as a job (only the file, named document.<type>), and Lens fetches the PDF. The node has LibreOffice and whatever else it needs, so a small server (a Raspberry Pi) reads Word, PowerPoint and spreadsheet files with nothing installed. Run it behind TLS off loopback (anytopdf queue serve --tls-cert … --tls-key …). Lens sends the file and token only to the address set: an http(s) address with no user name or password, and a node that answers with a redirect is refused, so the token never goes on to another server. A job's answer is read to 1 MB at most and its PDF to 2 GB, and a PDF that doesn't start like one is refused. Check it in Settings → Documents asks the node for a job with Lens's token and says whether it answered and took it.

Camera RAW and HEIC photos (.dng, .cr2, .cr3, .nef, .arw, .orf, .raf, .rw2, .heic, .heif) are photos Lens can't open itself, so anytopdf reads them wherever it is here or a node is set, whatever documents.converter says but lens: RAW from the preview the camera embedded, HEIC where libheif or ImageMagick is beside anytopdf. Without anytopdf, uploading one says what it needs, and folder scans pass them by. A RAW photo gets the Camera RAW photo content type.

It runs with no network, no runtime plugins and no config file of its own, and its PDF has no provenance page and holds only the document's text, so what Lens reads is what the document says. Faces, objects and speech stay Lens's own steps. The resource's rendition says anytopdf made it.

Lens can also ask anytopdf's face and object plugins what is in a picture (anytopdf.analyze). They run sandboxed (--plugin-sandbox strict: no network, writing only to their own workspace, reading only the picture and the models Lens names), and the objects plugin runs only when it's given a model, such as Lens's YOLOX file.

With video.face_engine set to anytopdf, the faces step uses these plugins too (Video): YuNet finds the faces and the face-id plugin describes them with Lens's SFace file. Lens reads each face's description from the face index of that run, then deletes it with the rest of the run.

Places and dates. With analysis.anytopdf on (Settings › Analysis › Find places and dates with anytopdf; off by default), analysis asks anytopdf, run here with no network and no plugins, what a document's text and a photo hold:

  • the places its text names, from anytopdf's offline gazetteer (GeoNames, CC BY 4.0), and the dates and times it gives, with their ISO value when the text pins one down ("3 March 2026", not "next Friday");
  • where a photo was taken: its GPS fix, named offline after the nearest town. anytopdf reads GPS with exiftool, so without exiftool beside it a photo gets no place. Each photo is read once.

They are hints: the places and dates join the names the extractor finds in each line (a name the rules only knew as a term becomes a place), and the namespace's entity setup still decides what is kept. What was found shows on the resource's Details tab, for the namespace's members only: visitors, embeds and share links don't see where a photo was taken. If anytopdf can't read something, analysis goes on without hints. Lens downloads anytopdf for this the same way.

Search photos by what they show. With embeddings.photos on (Settings › Search › Search photos by what they show; off by default), Lens keeps anytopdf's CLIP plugin beside the program and downloads its model, OpenAI's CLIP ViT-B/32 (about 600 MB, into data_dir/models/clip). The embed step then gives each image resource a CLIP vector (once per file, on the CPU, offline), and a search also shows the photos most like it, such as "a bus on a city street", in the namespaces and collections the person may see (GET /api/v1/search/photos). It needs no embeddings server, and its vectors are kept apart from search by meaning's. On a Raspberry Pi it works, slowly.

Neither may reach anything while converting: Chromium goes through a proxy inside Lens that serves the page and refuses every other request (the page also allows no scripts), and LibreOffice is given a proxy address that isn't there. Chromium runs with its sandbox where it can, and without it as root or where the container lacks what the sandbox needs; it's given no D-Bus to reach (it would wait on the services it asks there). The pages are JPEGs in data_dir/frames/<resource>/ (a page of about 300 KB at the default size) and the PDF made of a document is data_dir/renditions/<resource>.pdf; both go when the resource does. Workers listed in workers.steps run them as part of transcribe.

Text on screen and scans (OCR)

The OCR engine of video.ocr_engine reads text on a video's sampled frames (the ocr step) and on a document's pages without text and on images (when they're read, above):

  • auto, the default: Apple Vision on a Mac worker, else Tesseract, else RapidOCR, whichever is there first.
  • tesseract: Tesseract (in the Docker images), in the languages of video.ocr_languages.
  • apple-vision: macOS's own (pip install -e ".[mac-ocr]"), for workers on a Mac.
  • rapidocr: RapidOCR on ONNX Runtime (pip install -e ".[rapidocr]").
  • doctr: docTR (Apache-2.0), good on scans and photos of text; it reads the Latin alphabet. It runs on PyTorch: pip install -e ".[doctr]", or --build-arg EXTRAS="doctr" for a Docker image (several GB). Its two models (fast_base and crnn_vgg16_bn, about 130 MB) are fetched the first time it reads, into DOCTR_CACHE_DIR (~/.cache/doctr); where the server can't fetch them, put them there from docTR's GitHub releases.
  • none: nothing is read.

Without an engine (one that isn't installed, or models that can't be loaded) the ocr step is skipped and a document's scans aren't read; the job says why, and so does a video's Text on screen tab. Settings → Video, OCR, faces and objects:

Setting Default
video.ocr_engine auto auto, tesseract, apple-vision, rapidocr, doctr or none
video.ocr_languages eng Tesseract's language codes, one per line
video.ocr_min_confidence 60 lines on frames read with less confidence (0–100) are left out

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 (API), with the engine of video.object_engine:

  • yolox, the default: YOLOX on ONNX Runtime, both Apache-2.0. It needs ONNX Runtime (pip install -e ".[objects]") and a YOLOX .onnx model as YOLOX's releases publish them. Lens fetches both, with YOLOX-s, the first time a job needs them (Components), and also finds a model put in /opt/lens/models.
  • ultralytics: Ultralytics YOLO (pip install ultralytics). It's AGPL-3.0: a server that lets others use it must offer them its source, so it's in no image and no extra.
  • anytopdf: the same YOLOX model, run by anytopdf's objects plugin, sandboxed with no network. It needs no ONNX Runtime in Lens, for a lean server or a Raspberry Pi; Lens fetches anytopdf and YOLOX-s the first time a job needs them. anytopdf runs here even when documents go to a conversion node.
  • off.

Without one the step is skipped, and its job says why. Settings → Video, OCR, faces and objects:

Setting Default
video.object_engine yolox yolox, ultralytics, anytopdf or off
video.object_min_score 0.4 how sure the detector must be to keep what it found, 0.05–0.95

Set at startup only:

Setting Default
video.yolox_model the first yolox*.onnx in /opt/lens/models or the data folder's models a YOLOX model: YOLOX-s (35 MB, the one fetched), or YOLOX-Nano and YOLOX-Tiny (smaller and faster, less sure)
video.ultralytics_model yolov8n.pt Ultralytics weights, a file or a name Ultralytics downloads

On a CPU, YOLOX-s takes about a tenth of a second a frame. What's found is kept per kind and resource: where it's seen and its boxes on each frame or page (at most 500).

API keys

How long the API keys people make for scripts and other apps last (Authentication). Settings → API keys:

Setting Default
tokens.default_days 90 how long a new key lasts when its maker doesn't say, 1–3650 days (at most tokens.max_days)
tokens.max_days 365 the longest a key may last, 1–3650 days
tokens.never_expire false whether keys may be made that never expire
tokens.oauth_enabled true whether apps and AI assistants may sign people in with their Lens account (OAuth); off, discovery and registration are gone and apps' tokens stop working until it is back on, while people can still see and revoke the apps they allowed
tokens.oauth_access_minutes 60 how long the access token of an app someone signed in to lasts (OAuth), 5–1440 minutes; the app renews it by itself
tokens.oauth_refresh_days 30 how long such an app stays signed in after it last renewed its access, 1–3650 days (at most tokens.max_days)

The limits apply to keys made after a change: keys made before keep their expiry; apps get the new lifetimes the next time they renew. Settings → API keys also lists everyone's keys (whose, what scope, when they expire and were last used), and an admin can revoke any of them; that's audited as token.revoke.

Notifications

Where namespaces may send notifications (Notifications). Settings → Notifications:

Setting Default
notifications.enabled true off: nothing is sent, and what happens meanwhile isn't sent later
notifications.networks [] private networks targets may be in (192.168.1.0/24, 172.16.0.0/12 for Docker); without one, public addresses only
notifications.app_url null the web app's address, for links in messages; null uses FRONTEND_URL
notifications.poll_seconds 5 how often the notifier looks for news, 1–3600
notifications.max_attempts 6 how many times a message is tried, 1–20

Automations

How automations run (Running automations), the stream of activities they follow (Automations), their timers, and how long their approvals wait. Settings → Automations:

Setting Default
automations.enabled true off: no automation runs and nothing is lost; back on, they react to what happens from then on
automations.max_depth 3 how long a chain of automations setting each other off may get, 1–10
automations.max_runs_per_minute 60 runs a minute, all automations together, 1–6000
automations.keep_runs_days 30 how long runs are kept, 1–3650 days; each automation keeps its last 20 whatever their age
automations.catch_up 1h how late a timer (a schedule, an action that waits) may still fire after Lens was off, 0s to 7d; older ones are missed (Time)
automations.approval_days 7 how long an automation's action waits for your pick before it expires and nothing runs, 1–365 days
events.poll_seconds 5 how often followers look for new activities when nothing wakes them, 1–3600
events.keep_days 30 how long activities with no row of their own (the event log) are kept, 1–3650 days

Code sandbox

Where the assistant runs Python and bash (Code sandbox). Settings → Code sandbox; the image, CPU, memory, run time and how long sandboxes are kept are under its fold:

Setting Default
sandbox.enabled false on: the assistant gets run_python, run_bash, sandbox_files and import_sandbox_file
sandbox.who editors who may use it: editors (people who can edit a namespace, and admins) or admins
sandbox.network off off (no traffic), packages (the Python and npm registries) or hosts (those and sandbox.hosts)
sandbox.hosts [] with hosts: the names it may reach on ports 80 and 443, like api.example.com or *.wikipedia.org (up to 50); only at public addresses
sandbox.gateway openshell-gateway:17670 the OpenShell gateway's gRPC address, host:port; Run the gateway on this machine sets it
sandbox.image python:3.12-slim the container image sandboxes are made from; it needs python3 and bash
sandbox.cpu 1 cores a sandbox may use, like 1, 0.5 or 500m (0.1–64)
sandbox.memory 512Mi memory a sandbox may use, like 512Mi or 2Gi (64Mi–64Gi)
sandbox.seconds 60 the longest one run may take, 1–600 seconds
sandbox.idle_minutes 15 a sandbox nobody ran code in for this long is stopped (its files stay), 1–1440
sandbox.keep_hours 24 one unused for this long is deleted with its files, 1–720

Trusted proxies

IP groups (Access) match the visitor's address. The server takes it from the connection or, when the connection comes from a trusted proxy, from the X-Forwarded-For header that proxy sends, reading from the right past other trusted proxies. server.trusted_proxies (in the app: Settings → Access & embedding) lists them, as addresses or CIDR ranges. The default trusts this machine (127.0.0.0/8 and ::1), which suits the web app and the API on one machine.

  • List the web app. The browser reaches the API through the web app, which tells it the address it was reached from in X-Forwarded-For. A visitor's own X-Forwarded-For is dropped, so they can't choose it; the web app passes one on only from a reverse proxy (TRUST_PROXY_HEADERS=true) or from the hosts in LENS_WEB_PROXY_HOSTS (the Cloudflare tunnel's containers). This is done by lens-server.js, which the production image starts; next dev and next start pass the header on unchanged. The Docker Compose files list the web app for you: LENS_TRUSTED_PROXY_HOSTS=frontend,backend,worker trusts those containers by name (their addresses, looked up every 30 seconds, follow them when they're recreated; backend and worker run the Cloudflare tunnel). Elsewhere, list the web app's address here, or name its host in LENS_TRUSTED_PROXY_HOSTS. Sign-in throttles follow the same address, so without it everyone behind the web app shares one.
  • Behind a reverse proxy, set TRUST_PROXY_HEADERS=true on the web app and have the proxy set X-Forwarded-For: nginx with proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;, or Caddy, which does by default. The proxy adds the real address last, and the server reads that one. Without the setting, every visitor shows up as the proxy's address.
  • A request from an address that isn't trusted but carries X-Forwarded-For counts for no IP group, and neither does one from a trusted proxy that forwards nothing (the web app asking on its own behalf). A namespace's IP groups show your address as the server sees it, or say it can't tell.
  • The API server's own proxy handling (uvicorn's FORWARDED_ALLOW_IPS, 127.0.0.1 unless set) decides the address in its logs; IP groups follow server.trusted_proxies either way.

Security notes

  • The API checks the Host header against server.allowed_hosts (stops DNS rebinding) and sends a strict Content-Security-Policy; only the player (/embed/<id>, /s/<code>) can be framed, and only by server.embed_frame_ancestors.
  • Imports check the extension, cap the size (server.max_upload_mb), and are parsed in a temporary directory; nothing uploaded is executed. Uploads accept only the types in uploads.extensions (each is served back as audio or video, or as a download; documents and images only as downloads, and as page images Lens drew), under a name with no folders in it.
  • Put everything behind HTTPS before exposing it beyond one machine. IIIF authorization requires it.