Skip to content

Changelog

The backend (fastapi_backend) and the frontend (nextjs-frontend) are versioned together.

Unreleased

0.20.0 October 20, 2026

  • Photos and videos on a map. Library → Map (beside Table and List, on a phone too) shows each asset whose own file said where it was taken as a dot, with the Library's filters, framed to show them all; a dot links to its asset. Besides a photo's EXIF GPS fix, a video's location is now read as it arrives (the ISO 6709 tag iPhones and Android phones write). Agents get the same through GET /api/v1/resources?located=true&bbox=west,south,east,north, which also gives every row its place, and the MCP tool list_recordings (located, bbox); only members see where an asset was taken. The tiles under the map come from Settings → Maps (OpenStreetMap's by default); each viewer's browser fetches them, so the tile server sees the area looked at: point it at your own tile server, or leave it empty for none (Where an asset was taken, Maps). Assets that arrived before keep what they had: nothing needs upgrading.

  • Blur faces for sharing. A video's People tab has Blur faces for sharing: every face found is ticked, and Enter makes a copy of the video with those faces blurred, by ffmpeg over the faces' tracks, in the background as a heavy step (quiet processing hours apply), with its progress shown. The copy is kept with the video's files, the original stays as it is, and an owner can have visitors (public pages, embeds, share links, IIIF, feeds) play the copy instead. Nothing changes for a video until someone asks. Agents use GET and POST /api/v1/recordings/{id}/blur-faces (all, unnamed or chosen faces), PUT .../blur-faces/visitors, and the MCP tools blur_faces, get_face_blur and set_face_blur_for_visitors (Blurring faces for sharing).

  • Line up a transcript with its audio. A transcript imported without times (plain text, Word, PDF, Markdown) now says its times are estimated, and once its audio is attached its page offers Line up with the audio: one click transcribes the audio in the background with the engine transcription already uses (activity ledger, budgets and quiet processing hours apply), matches the transcript's words to the words heard, and gives each line their times; lines none of whose words were heard sit between their neighbours. The text and speakers stay exactly as they were. Nothing is lined up unless someone asks. Agents use POST /api/v1/resources/{id}/align and the MCP tool align_transcript; GET /api/v1/resources/{id} says times (estimated or aligned). Upgrading marks transcripts imported before with estimated times (the estimated-times step) (Lining up a transcript with its audio).

  • Thumbs up or down on answers. Under a finished answer in chat, a thumbs up marks it good and a thumbs down bad; pressing the one you gave takes it back. The rating is saved on the answer, shown when the conversation opens and carried in its JSON export (lens.rating), the label training data is built from; it makes no model call. Agents rate answers with PUT /api/v1/chats/{cid}/messages/{mid}/rating or the MCP tool rate_answer, and read it back on each message (Rating answers). Nothing needs upgrading: older answers have no rating.

  • An empty chat names the next step. A new conversation with nothing in its scope yet (nothing imported, or none in the namespaces picked) says chat has nothing to answer from and offers Import assets, plus Change scope when namespaces are picked, instead of suggesting questions nothing can answer. Someone who can't import is told an editor or admin can (Chat). Nothing needs upgrading.

  • Google service accounts that act as someone sign in with the scope the admin delegated. Google matches a domain-wide delegation's scopes exactly, so a client delegated all of Gmail (https://mail.google.com/, as backup tools ask for) was refused gmail.readonly and gmail.compose, and the source said only "domain-wide delegation" with the fix cut off. Lens now signs in with the broader scope the admin gave (all of Gmail, Calendar or Drive) and still calls only what the narrower scope allows. When Google refuses, the source's error names the scopes the delegation lacks, the ones it allows and the exact list to paste in the Admin console, in full (Gmail).

Dependencies

  • bump aiohttp from 3.14.3 to 3.14.4 in /fastapi_backend (#698)

0.19.0 October 19, 2026

  • Circles in the web app. Your circles are under Profile: a suggested name makes one in one click, people are added from a list and taken out with ×, and deleting one is undone from its message. Share on a timeline item, or Share with a circle… in a resource's menu, picks your only circle, takes a note, and is undone from its message; shares show under Shared in the timeline (Circles of trust).

Hovering a reaction on the timeline says who gave it ("You, Sam and 3 others"), and the API, MCP and the page carry up to five names of the others who gave each one.

  • Lambda GPU instances for auto scaling. A Lambda provider launches GPU instances in your Lambda Cloud account that join Lens over the mesh as worker nodes and transcribe and tell voices apart, priced from Lambda's list, found by Lens's tag and terminated when the group scales in (Machines elsewhere).

  • A search result opens the asset with its words found. Results link with ?q=, the words searched for (not filters or left-out words), so the asset opens with Find in its text showing them, on the match at the moment the result was, or on the longest of the words when the text doesn't have them together (Search).

Comments on the timeline can be edited in place (Enter saves, Escape cancels), and a thread can be resolved or reopened there, as on an asset's page.

  • Amazon Bedrock as the LLM provider. Set the address to https://bedrock-runtime.<region>.amazonaws.com, the model to a Bedrock model id or inference profile, and the key to an AWS access key: Lens's chat, tool calls, images and summaries go to Bedrock's Converse API, signed with the key (LLM provider).

The timeline has saved views: a name for a set of filters and an order, picked from a Views menu on the page, saved from what the page shows with a name guessed from the filters, and listed, saved and deleted by agents over the API and MCP (timeline_views, save_timeline_view, delete_timeline_view). Each person's views are their own.

  • Amazon Bedrock provisioned throughput for auto scaling. A Bedrock group holds provisioned throughput of a model in your AWS account, with no commitment and at the price you give, and Lens's model calls go to it while it runs, signed with the provider's AWS key (Model hosts).

A file a watched folder finds changed in its source now shows on the timeline as "Office share updated “Minutes”" (file.changed, an Update that links the asset it was before) rather than as a second "added". Automations on new assets fire on it as before.

  • Share with your circles on their own. The share_with_circle automation action shares what comes in (the school's emails, a calendar's events, a sensor's alerts) with a circle and a note that can say what came in. It asks you first, unless you let its node run on its own; it shares as you, only what you can see in the automation's namespaces, never from a vault (Rules).

The mute menu on a timeline item now snoozes a source, person or kind for an hour or a week as well as a day.

  • When they're free. Let a circle see when you're free (never what): its people and their agents ask POST /api/v1/circles/when-free, MCP when_free or the assistant for a window and get each person's busy times and when everyone is free, within the hours of the day asked for. Your events are those you organise or are invited to, and your own calendar's; each ask shows in your timeline as agent.asked (When they're free).

Review marks and reactions left on timeline activities that are gone (an event log entry past events.keep_days, a deleted asset) are now dropped by an hourly tidy, a batch at a time. Comments on them stay.

  • Not them here. When a voice in one asset was matched to the wrong person, Not them here on their card in the asset's Speakers tab (or POST /api/v1/speakers/{sid}/detach) gives its lines there to a new person, works out the first person's voice again without it, and never suggests the two as the same; Undo merges them back (People and namespaces).

  • Ask their agent. Let a circle ask your agent questions about the namespaces you pick: it answers only from them, citing what it used, and you see each question and answer in your timeline. Through POST /api/v1/circles/ask, MCP ask_their_agent, the assistant, and Profile → Circles (Ask their agent).

For you on the timeline now counts what you open: following a line to what it is about, or opening an item by its address, lifts its source, kind and entities a little for you alone (up to four opens of one item).

  • Auto scaling finds units Lens lost track of. Every ten minutes each provider is asked for the units it runs with Lens's tag; one no unit of Lens's stands for (a launch whose answer never came back, say) shows on the provider in Settings › Auto scaling and the API, and goes in the activity ledger once. Lens lets none of them go itself: you do, with Let go (and Undo for a few seconds), and the assistant can only ask you to (Units Lens lost track of).

The mute menu on a timeline item can now snooze its source or kind until a time you pick (Mute Work Gmail until…), starting at nine tomorrow morning; Enter mutes.

  • A scaling unit that goes leaves the mesh. When auto scaling lets a unit go, or one fails or never comes up, its unspent join token is withdrawn and the machine that joined with it is taken off the mesh, as removing it on the Nodes page does: its certificates go on the blocklist and its database user is removed (Machines elsewhere).

  • Search finds moments by the sounds in them. Laughter, applause, music and the other sounds a speech provider hears are a search filter like emotion: type event:laughter (any case; music is the BGM tag), pick one under Sounds in the filters, or pass event to GET /api/v1/search, a saved search or the MCP search tool. Each hit says its sound, and the facets count them (events), leaving plain speech out (API, search).

For you on the timeline reuses each person's affinity for a minute while their reactions, comments, marks and opens stay the same, so paging through it looks up fewer activities.

  • SageMaker and Bedrock answers stream. With an Amazon SageMaker endpoint or Amazon Bedrock as the LLM provider, a chat's answer now comes as it is written (InvokeEndpointWithResponseStream and ConverseStream) instead of all at the end, and an error the model hits mid-answer says so. Keys also allowed sagemaker:InvokeEndpointWithResponseStream or bedrock:InvokeModelWithResponseStream stream; keys without them get the answer whole, as before (LLM provider).

  • Model calls spread across a group's units. While a model group serves Lens's model calls, every running unit of it takes them in turn, not only the one Settings › LLM provider shows, so scaling a group out adds capacity. A group's units now share one key, sealed on the group, and the scaler keeps the new llm.spread setting at their addresses; a unit that can't be reached hands its call to the next (Auto scaling).

  • Photos and videos are dated by when they were taken. An asset uploaded, scanned or imported is now dated by its own file when its name gives no date: a photo by its EXIF (DateTimeOriginal, else DateTime), a video or a recording by its creation time (QuickTime's creationdate, else creation_time), before the file's last change. A photo's GPS fix is kept as its place (lat, lon), which GET /recordings/{rid} and MCP get_recording give its namespace's members. A date in its name, or one its source gives, still wins, and a clock never set (1970, 1904) dates nothing (When an asset was made).

  • Lambda as a model host. A lambda-models provider runs a model with vLLM on Lambda GPU instances, which serve Lens's model calls while they run. Each instance serves HTTPS with a certificate signed by its group's own certificate authority, which Lens makes and keeps sealed, and Lens trusts only that authority for it (the new llm.tls_ca setting); a firewall ruleset Lens makes once per region opens the port (Auto scaling).

  • Circles with other servers. Once an admin turns it on (Settings → Circles elsewhere) and you make yourself findable under Profile, you get a handle like adeel@lens.example, and your circles can hold people on other Lens or ActivityPub servers by theirs. What you share with those circles reaches them as a signed ActivityPub Note, and what they send you lands in your timeline, taken only signed, recent and from someone in a circle. Through the API, MCP circles_elsewhere and save_circle, and Profile → Circles (Circles with other servers).

  • Access that ends. Giving someone a role in a namespace, a role on a collection or permission on an asset now takes an optional end date: an "Until" day in the asset's Access dialog, a collection's People and a namespace's members, expires_at in the API, and until on the assistant's change_person. After it, the grant opens nothing anywhere access is checked, and lists show it as expired until it's given again or taken away. The field starts empty, so grants without an end work as before (Grants that end).

  • A downloads switch for each asset. Its owners can turn downloads off in its Access dialog or with PUT /api/v1/resources/<id>/access {"downloads": false}. Then everyone else who sees it still plays it, sees its pages and reads its text, but saving its own file, the PDF made of it or its supplementary files answers 403 with the reason; the web app disables those download buttons and says why, the file tree and IIIF manifests leave the files out, and owners keep saving. It's on for every asset until someone turns it off. It isn't copy protection: what plays can still be recorded (Downloads).

  • A bulk import is one run you can undo whole. A folder scan, a zip or tar archive's files, a list of links or a bookmarks file, and chosen files of a source each make one import run, and every asset it makes carries the run's id (import_run). GET /api/v1/import/runs lists recent runs with how many assets each made and how many are left; POST /api/v1/import/runs/{id}/undo deletes them all through the normal delete, needing owner rights on each (nothing goes when any isn't yours to delete), audited per asset and for the run. The toast after a link or source import has Undo, the Import screen lists recent runs with Undo on each, and the assistant has import_runs and undo_import (always after your yes). Assets imported before keep no run, and nothing changes for them. (Import runs)

  • See the words transcription wasn't sure of. Whisper, Deepgram, AssemblyAI and ElevenLabs say how sure they were of each word, and Lens now keeps it with the word's timing instead of dropping it. In the transcript, a word below the threshold gets a dotted gold underline and a tooltip ("Transcription wasn't sure of this word (42%)"), so you can spot it and correct it in place; correcting it takes the mark away. Settings → Transcription → Mark words it was less sure of than sets the threshold (transcribe.confidence_highlight, 0.6; 0 marks none). GET …/player gives each word's confidence as a fifth in w, and the threshold as unsure_below; the MCP tool get_transcript lists each line's unsure words. Transcripts made before, and those from SenseVoice and OpenAI's own API, which don't say, have no marks and work as before (Words transcription wasn't sure of).

  • Follow a collection in your podcast app. A saved collection's page has Podcast feed: your private address for it, with Copy and New address. Its episodes are the collection's assets with audio or video that you can read, with the same RSS and Apple Podcasts' tags, audio and private-address rules as a namespace's podcast feed. Everyone who may read the collection gets an address of their own, and it stops when the collection is deleted or no longer shared with them. Agents get it with GET /api/v1/collections/{id}/feed or the MCP tool list_feeds with collection_id (A collection's podcast feed).

  • Search finds what a pipeline's model step wrote. The words of the output an llm step saves on an asset (and of a workflow's Save output node after an LLM prompt node) are found by search, the API's GET /search and MCP's search, as source: generated with the step's name in output_label; the web app shows "Written by ". Running the step again replaces them and deleting the asset removes them. A step keeps its output out of search with Find what it writes in search unticked (searchable: false); it is on otherwise. The upgrade step generated-search makes outputs a model wrote before searchable too (Pipelines and templates).

  • Translate a transcript. The Files tab's Translate… has the configured model translate an asset's transcript into another language (the one your browser reads is filled in), showing what it would take first, always labelled an estimate. It runs only when asked, in the background, sending the lines in batches marked as data so instructions in a transcript are never followed, and keeps the result as a translation file whose lines keep their times and speaker, read by search, the Files tab and IIIF like an uploaded one. Every call is in the activity ledger; a budget that would go over holds it, and one used up stops it. Agents use GET and POST /api/v1/resources/{id}/files/translate, the MCP tools translate_transcript and get_translation, and the assistant's translate_transcript, which asks first (Translating a transcript).

  • Clips and playlists. Keep a moment of any recording, video or document as a clip, with a title and a note: select words in a transcript and choose Add to playlist, or add a highlight from its tab. One click puts it at the end of the playlist you used last (a new one called Clips when you have none), with Undo. The new Playlists page lists yours and the ones others shared; a playlist's page plays its clips back to back on each resource's own audio or video, reorders them by dragging or with Up and Down, and takes one out with Undo. A shared playlist shows each reader only the clips of resources they can read. Agents use /api/v1/playlists and the MCP tools list_playlists and add_to_playlist. New tables only: nothing to do when upgrading (Playlists).

  • Automations fire on any entity of a type, or names matching a pattern. A trigger's where (and an If, and a condition node's tree) can test what an activity mentions: {"op": "mentions", "value": {"type": "PERSON"}} fires on any person, {"name": "Acme*"} on names like Acme, with * and ? as wildcards, and {"id": "lens:entity/9"} on one entity. A processed asset is now tagged with its fifty most mentioned entities, not ten, and every entity tag carries its entityType. Conditions saved before work as they did (Operators).

  • A date said becomes a calendar event, once you approve it. Next to each date said in an asset (its Entities tab, under Dates said), Propose event makes an event for a Google Calendar connection an admin let add events, prefilled from the line: the day said at a time said in it ("at 3" is 15:00) or all day, the line as its title, and who it names (for an email, its sender and recipients), never emailed unless you ask. It waits in Approvals, and nothing is added or sent until you approve it there, signed in; declining it does nothing. Agents propose through POST /api/v1/dates/propose and the MCP tool propose_event, and can decline but never approve (Propose an event).

  • WebVTT voices name the speaker. A transcript imported from a WebVTT file now credits each line to the speaker its voice span names, <v Name> or <v.loud Name> with classes, closed by </v> or not, and those speakers join the namespace's people like any other named speaker. A cue with two voices becomes two lines that share its time; the tags are left out of the text, and a cue without a voice is read as before. A YouTube caption cue that starts with a line of one space is no longer lost (Importing text and transcripts).

  • Captions the browser knows about. A video now plays its transcript as a WebVTT <track>, in the workspace and on its public page, so the browser's captions menu, full screen, picture in picture and assistive technology get it, with timed supplementary captions and translations as more tracks to pick. The links are signed, so they work for visitors and share links too. In the workspace the Captions toggle still draws the line and its speaker over the video, and the browser draws the track only where that can't be seen (full screen, picture in picture), so captions never show twice; on the public page the browser draws them once the visitor turns them on with the new Captions button. GET /api/v1/recordings/<id>/player and GET /api/v1/public/recordings/<id> list the tracks as captions (Captions).

  • A voice isn't given back to someone it was said not to be. Once a voice was split off from a person (Not them here) or a suggested pair was marked not the same, a voice in a new or re-diarized asset goes to, or is suggested as, only the one of the two it sounds more like, by their voice overall or in any one asset they're in. Before, a voice nearer the first person's average could be matched or suggested as them again (People and namespaces).

  • A "not the same" decision survives a merge. Merging one person into another now carries every pair someone said aren't the same person over to the person merged into, so a voice isn't suggested again for someone it was said not to be; a pair that would name the person twice is dropped, and undoing the merge puts the pairs back. An upgrade step (speaker-not-same-merged) does the same for merges made before (Upgrades).

  • Correct an asset's language, with its region. The language transcription hears stays on the asset, and editors 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) in the asset's Details tab, with PATCH /api/v1/resources/<id> {"language": "en-gb"} or the MCP tool set_language. The tag is checked and written the usual way (en-GB, zh-Hant-TW), the IIIF manifest and Dublin Core record give it, and transcribing the asset again keeps a person's language instead of the detected one; null forgets it. A pipeline step's languages condition of en now takes en-GB and en-US too. Languages already stored stay as they are (An asset's language).

  • Name the unnamed faces in a row. Where a namespace recognises faces, Settings → People has an Unnamed faces tab (and a video's People tab links to it): the faces nobody has named yet, the largest group first, each with a few crops and the assets it's in. The first name field has focus, filled with the name Lens offers when it has one (the voice the face is linked to, or a named face suggested for it); Enter names it and moves on to the next, with Undo. A name another face already has joins that person, the same merge as "Same person". Agents do it with GET /api/v1/namespaces/<ns>/faces/unnamed and POST /api/v1/faces/<id>/name, or the MCP tools list_unnamed_faces and name_face (Naming faces).

  • Search by when, tag, source and language. GET /api/v1/search takes from and to (days: when the asset was recorded, or added when its file doesn't say), tag, origin and language, the Library's filters of the same names, and keeps every kind of hit to the assets that have them; the MCP search tool takes date_from and date_to (a day, a month or a year), tags, origins and languages. On the search page they're compact controls at the top of the filter panel, empty until picked, typed in the box too (tag:interview, from:2026-01-01), shown as chips, kept in the link and in saved searches. A search's object filter now also keeps text on screen, file lines and descriptions to the assets the object is seen in, not only what was said (search).

  • Notes and comments in IIIF, as W3C Web Annotations. With notes added to the published annotation layers (Settings, IIIF; off by default), an asset's IIIF Manifest lists its shared notes and its comments as an AnnotationPage (/iiif/<id>/annotations/notes): each note or thread commenting on its moment or on the whole asset, each reply replying to its thread, with who wrote it and when; on a document, per page. Only people with a role on the asset get it, through their sign-in or the IIIF access cookie: the public, share links and signed links never do, and unshared notes are never in it (IIIF).

0.18.0 October 18, 2026

  • Client certificates for log servers. Settings → Logs takes this machine's certificate and key (PEM, stored encrypted) for syslog over TLS and https log endpoints that ask for one (mutual TLS), and a private CA's certificate to check the log server against (Logging).

The Mute button on a timeline item now has a small menu beside it: mute the source or person for a day (a snooze that runs out by itself), or mute the item's kind, for good or for a day, without leaving the page. The Muted row shows when a snooze ends.

  • Voice recognition off, per namespace. An owner can turn off People › Recognise voices across recordings: voices are still told apart in each recording, but no voiceprint is kept and the namespace's voiceprints are deleted (People and namespaces).

  • Circles of trust. Put the people close to you in circles of your own (Immediate family, Close family, Friends, Colleagues are suggested) and share an activity from your timeline or a resource with a circle and a note: each of them gets it in their own timeline as share.received, which their automations can follow, and you get share.sent. With the item also gives them permission on that one resource (it needs publish); undo takes the share back everywhere, with the permissions it gave. Nothing in a vault can be shared. Works through /api/v1/circles, MCP (list_circles, save_circle, share_with_circle, undo_share) and the assistant, which asks first (Circles of trust).

  • Ask where budgets stand. The assistant answers "where do my budgets stand?" with a new budgets tool, and agents call it over MCP: what each namespace you can read spent this period against its budget, what's left and whether it's ok, near or over, and for admins all of Lens against the overall budget and every other budget (Budgets).

The timeline opens one item by its id (/timeline?item=<id>), above the rest with its details and actions and a way back. A comment or a reaction on an item, and an approval's "because", now link to that item rather than to the timeline as a whole.

Reinstalling in a microsandbox no longer stops at "Getting uv". A sandbox whose volume an earlier install had built in asked the backend's virtualenv, which has no pip, for uv and failed with "No module named pip". It now asks the image's own Python. Rerun the install line to pick it up.

0.17.0 October 18, 2026

Features

  • microsandbox: the MCP runner runs inside the sandbox, turned on and off from the web app like Docker's; it runs as a user of its own with the database and the archive closed to it

  • A new conversation's first answer no longer goes missing. A quick answer in a new conversation sometimes showed "No answer was saved for this question" though it was saved: the conversation's first read, made before the answer was saved, was used in place of reading it again. The web app (Chat, the page assistant and a recording's Chat tab) now drops a read under way and reads the conversation afresh once the answer is saved. And an answer is now written on a thread of its own, so closing the tab, losing the connection or moving to another page no longer drops it: it's finished and saved all the same, and only Stop ends it early (Chat).

  • The code sandbox can run on compute nodes. Once the mesh registers the install's compute nodes, run_python and run_bash take a node to run on, sandbox_nodes lists where code can run and why a node can't now, and a conversation's sandbox stays where it last ran (moving starts a fresh one). Each node's sandboxes are stopped, swept and deleted through its own gateway, and each run's ledger row names its node. Until nodes are registered nothing changes (Compute nodes).

  • Choose the content type as it comes in. Import has a What it is pick beside each file, pasted text and web page, offering the content types of its base type (Recognised automatically until you pick one). POST /uploads, POST /import, POST /import/web and /import/web/links take a content_type, and the assistant's import_files and import_urls take one when you say what the files or pages are: the asset is that content type from the start, so its pipeline runs the first time, not after a change and a rerun. One of another base type (a podcast for a photo) is refused before anything is saved, naming the ones that fit. Without one, nothing changes: it's recognised from the file (Content types).

  • lens ext: extensions as files. lens ext pull writes your extensions' manifests into a folder (skills and prompt tools as Markdown, the rest as YAML), lens ext push saves the changed ones as new versions and the new ones as extensions, lens ext check checks them without saving, and lens ext list lists them (--json for agents). It works on this install's database, or on any Lens server with --url and an API token. A pulled file says its version, so a push won't save over changes made in Lens since, and a pull won't overwrite changes made in the file, without --force (Manifests as files).

  • Transcription expects your own names. With Settings → Transcription → Expect your own names on (off by default), each recording is transcribed knowing up to 60 words its namespace already knows: the custom vocabulary, its named people, its defined entities and the names people fixed in its transcripts. Whisper and OpenAI-compatible servers get them as the prompt, Deepgram as key terms or keywords, AssemblyAI as key terms or boosted words; SenseVoice and ElevenLabs take no hints (Your own names in transcription).

  • Nebula on the primary (the private network between Lens's machines, in progress). On the primary Nebula runs in the optional mesh container, which the upgrade agent starts when a node is added: it makes its own key pair, checks Nebula's download against the kept checksums and relays the database and the API on the mesh address only. The install line that joins a machine and the Settings pages follow in their own pull requests (Nodes and the mesh).

  • Projects. A project is a goal you work toward with the assistant: a name, the goal in your words, instructions and the namespaces it draws on. It comes with a coordinator conversation about the project as a whole, which is told about every thread, and its threads are conversations scoped to its namespaces, marked done when finished; your own conversations can be brought in or taken out, and deleting a project keeps them. Every answer in a project reads its goal and instructions. The coordinator starts threads with a brief (start_thread), reads them and marks them done; a brief, from it, you or an agent, is answered in the background as you, with your tools, approvals and budgets. The Projects page, at the top of Chat's conversation list, starts a project with just a name, starts threads with a brief on Enter, shows which are working and marks them done in one click. Schedules ask a prompt on a schedule, in a new thread each time or the same one, and wait while switched off or the project is archived; agents set them with the MCP tool schedule_project. /api/v1/projects and the MCP tools list_projects, read_project, create_project and update_project do it all for agents (Projects).

  • Feeds in the web app. The Feeds page, opened from Podcasts and under Explore once there is a feed, lists your published feeds; New feed asks for a title, kind and namespace (Enter makes it); a feed's page copies the address in one click, gets a new one, edits what readers show, takes items out and deletes the feed with Undo instead of a confirm. The publishing nodes are on the canvas, with the feed picked from a list (In the web app).

  • Grammar fixed by LanguageTool, when you run one. With transcripts cleaned up and Settings → Analysis → LanguageTool server set (none by default; the compose files' new languagetool profile starts one at http://languagetool:8010), the clean step makes its grammar and punctuation fixes too, leaving spelling, style and names alone. Each change is in Change history with Revert; without the server the rules run alone (Cleaning up transcripts).

  • Routine run alerts. A routine's run that fails (all its actions, some of them, or one that stops without finishing) or leaves graph changes to review can now be sent through Matterbridge, to a signed webhook, or to Slack and Discord: pick the new routine.failed and routine.proposed events for a namespace's notification target. Each namespace the routine runs over gets its own message, linking the routine or the run's changes; new targets' default events are unchanged (Notifications).

  • Talk to the assistant from a timeline item. An opened item on the Timeline has Ask about this, and a on a line does the same: the chat beside the page opens with the item (its id, line, time, where it opens and its fields) as what your question is about, read as data, so you type the question and press Enter. Nothing changes until you use it (timeline).

  • Joining a node (in progress). Another machine joins with the install line and LENS_JOIN and LENS_JOIN_TOKEN: it asks nothing, publishes no port, and runs Nebula and lens node, which joins once and then follows the primary's config, renews its certificate and reports over the mesh. Adding a node token now returns that install line (On a node).

  • Clear the job queue, reorder it and retry failed runs in bulk. Activity has Clear queue (it asks, then can be undone) and Retry failed, and under Queued the runs show in the order workers take them and can be dragged into another order (or moved with Alt+↑ and Alt+↓, or Move to top and Move to bottom). The API has the same: POST /jobs/cancel cancels every queued and held run you may process (or one namespace's, or chosen ids), POST /jobs/{jid}/move moves a queued run, and POST /jobs/retry retries every failed run, the dead letter queue. Agents use the MCP tools runs, clear_queue, move_run and retry_runs (Background work, the API). A retried run now joins the back of the queue.

  • Enhance low quality images before they're read. The new enhance step, first in the standard pipeline, measures each image (size, sharpness, noise, contrast, and face size where the namespace finds faces) and keeps an enhanced copy of a low quality one, which OCR, faces, objects and descriptions then read; the original stays as it is. Off by default: Settings → Enhance. The light classic engine (Pillow) is the default; Real-ESRGAN general x4v3 (BSD-3-Clause, 5 MB) is fetched the first time it's chosen. The Details tab says what was found, saves the copy, and enhances an image on request; agents use POST /api/v1/resources/{rid}/enhance, the MCP tool enhance or the enhance automation action (Enhancing images).

  • Whisper keeps the language it heard. With no language set in Settings › Transcription, recordings Whisper transcribes now keep the language it detected, as other engines' do, for search filters and the text cleanup.

  • Worker nodes run jobs (in progress). On a worker node, lens node also runs lens worker, against this install's database through the mesh, signed in as a user of that one database made for the node (removed with it; SURREAL_AUTH=database). Its jobs fetch their files from the primary over the mesh, plain, never a vault's or another namespace's, and it never gets the archive's secret, so it runs transcribe and diarize with the models on that machine, skipping any other step LENS_WORKER_STEPS names. A worker that serves some namespaces takes only their jobs, and an auto scaling group on machines elsewhere counts only the jobs a worker node takes, so it never launches one for others (Worker nodes).

  • Only the person approves what the assistant asks. Sending mail, adding an event, writing a file and the assistant's other proposals are approved only by the person signed in to Lens, or in the chat they were asked in. An API key or an app given access can now only decline them (POST /api/v1/approvals/{id} answers 403 to an approve or a sample), as MCP and the assistant already could, and each approval's can says so (Approvals).

Email replies. Lens drafts a reply to an email a watched mailbox brought in, from what that email's namespace holds: each fact cited to its excerpt, what the archive doesn't say marked [to confirm: …], addressed to the sender and threaded. Drafts wait to be edited or set aside and nothing is sent; ask through the API or MCP (email_replies, draft_reply, get_reply, edit_reply), or add the new reply step to a pipeline to draft each new email, skipping mailing lists, no-reply senders and your own mail without a model call. The prompt is the Reply to an email template (Email replies).

  • Agents find, install and update marketplace plugins. find_plugins and install_plugin (over MCP, and in chat, where installing waits for your yes) look across every marketplace added and install a plugin as your own extension. A plugin you installed now says which version you have, and its button reads Update to the version its marketplace lists when that's another; GET /api/v1/marketplaces/plugins finds plugins across marketplaces (?updates=true for those with another version) (Skills and plugins from marketplaces).

  • Settings › Nodes and Network (in progress). Nodes adds a machine (its role, and optionally a name and namespaces) and shows the install line once, with Copy and how long it works. It lists every machine, this one first, with its mesh address, when it was last seen, Nebula, its Lens version, certificate, namespaces, worker and log, renames in place, and removes a machine with Undo instead of a dialog. Network shows whether the mesh is off or running, the CA, this machine's mesh address, the mesh settings and what Nebula said (the mesh).

The Replies page. The email that waits for an answer, in the menu while any does: Enter drafts the first one's reply, a draft shows with its sources and what's left to confirm, and Open in your mail app or Copy takes it without its citations. Edits save with Ctrl+Enter; Set aside offers Undo. An email's More actions menu has Reply to this email….

  • Leave words out of a search. budget -draft or budget NOT draft finds the lines about the budget that don't mention a draft (or drafts), and -"first draft" leaves out a phrase; a typed AND changes nothing, as every word is needed anyway. It works in the web app, the API, MCP and the assistant's search, and the "?" help shows it.

  • Compute nodes run the assistant's code (in progress). A compute node's install line now has LENS_NODE_SANDBOX=1, which starts an OpenShell gateway on that machine. lens node hands Lens the gateway's client certificate once, kept sealed, and reports whether the gateway answers. Lens reaches the gateway over the mesh only, with mutual TLS, so sandbox_nodes lists the node and run_python and run_bash can run there (Compute nodes).

  • A copy you have already isn't read again. The same file in two folders of a source on this machine (or uploaded, or found by a scan) is one resource, matched by what's in it, so it's transcribed once; a web page or a video imported by an address the namespace has already gives that resource back, with duplicate true, instead of fetching and reading it twice. Copies on cloud storage are still matched by their path (Imports).

  • Import webhooks can hand back PDFs. A light server without LibreOffice or Chromium, such as a Raspberry Pi, can now take Word, PowerPoint and Excel files, emails and saved web pages through an import webhook whose owner turned on hand_back: the file is kept and waits, the answer says needs_pdf, and the conversion node that sent it pushes the PDF back with ?for=<recording>. That PDF is what the document's pages are drawn and read from, and its pipeline runs then. It's off on every hook, and nothing changes on a server that converts those files itself (PDF hand-back).

Reactions and comments on the timeline. The timeline is a feed everyone in a namespace talks on: an opened item has a React button with a palette (👍 ❤️ 🎉 👀 😂 😮 😢 🙏) and shows everyone's reactions as chips, and its comments open as threads with Reply, where Enter sends. A closed line sums its items' reactions and says "3 comments". A comment on an asset's arrival is a comment on the asset, so the same thread shows on its page. Each reaction is a reaction.added activity and each comment a comment.added, so automations can act on them, and agents react and comment over MCP (react_to_activity, activity_comments, comment_on_activity) and through POST /api/v1/activities/{id}/react and /activities/{id}/comments (Reactions and comments).

Send a reply. A reply to an email that came in through a Gmail connection an admin let send goes out from the Replies page, from that mailbox and in the email's thread, without its citations: Send waits a few seconds with Undo, and Keep in Gmail drafts puts it in Gmail's drafts. Nothing marked to confirm may be left in it. Agents ask with the MCP tool send_reply, which waits in Approvals showing all of the message and sends the text approved (POST /api/v1/recordings/<id>/reply/send for a person signed in; Sending).

  • Playable copies. With Settings › Video › Playable copies on (off by default), media browsers can't play, such as HEVC in a .mov, AVI, MTS or AMR, gets one copy they can (H.264 and AAC in MP4, at most 720 lines high by default), made by the transcribe step. The player gets the copy; downloads still give the file itself (Playable copies).

  • A pipeline's Read step picks its own converter. One pipeline can make its documents and photographed pages into PDFs with anytopdf while the rest keep the server's choice in Settings → Documents: set Make PDFs with on its Read step, or converter (auto, anytopdf or lens) in the step over the API and MCP. An image read again by Lens itself no longer keeps pointing at a PDF an earlier anytopdf run made of it (processing).

  • Clean up a recording's sound before it's transcribed. A new pipeline step, Clean up audio (clean_audio), runs a recording through WebRTC's audio processing (pywebrtc-audio, Apache-2.0, about 0.5 MB, fetched the first time it runs): noise suppression, automatic gain, a high-pass filter, echo cancellation for two-channel call recordings, and pauses left out of what's transcribed, with the transcript's times put back on the recording's timeline. Settings → Transcription → Audio cleanup holds what it does; no pipeline has it until you add it, the file is never changed, and GET /api/v1/recordings/{id} says what it found (audio_cleanup) (Cleaning up audio).

  • Shared routing rules for an email account. An IMAP or Gmail account can carry routing rules that every watch on it uses, so a shared inbox's policy (support@ to support, newsletters skipped) is written once rather than on each mailbox's watch. A watch tries its own rules first, then the account's, then its namespace; with no shared rules nothing changes. They're edited from the account's More menu on the Sources page or saved with PUT /api/v1/sources/{id}/routes; the routes preview takes shared_routes and says which list decided, and agents read and change every account's and watch's rules with the MCP tools mail_routing and set_mail_routing (Email).

  • Automations in the web app. The Automations page lists each automation's triggers in words, with its runs today and when it runs next, and Schedules is now a tab of it. The When, If and Then builder drafts an automation from a sentence or from one of five starters, which stay off until you turn them on, and its test panel tries it on a sample without changing anything. An automation's page shows its runs, with an action that waits marked as waiting, what's coming up, its saved variables and its costs. Agents list the starters at GET /api/v1/automations/starters. The Automations guide now reads as one guide, starting with a five-minute walkthrough, and every automations entry in the threat model names the test that proves its mitigation.

Tag @lens in a comment and the agent answers in the thread. On the timeline or on an asset's page, a comment with @lens (or the assistant's own name) in it gets the agent's answer as a comment by Lens on the same thread, as the person who tagged it, with their tools, in a conversation of theirs titled after the item; every change it proposes waits for their approval, and the reply links where to decide. Off with Settings → AI assistant → Answer when tagged in a comment (Asking the agent in a comment).

  • Clean up only low quality recordings. The clean_audio step can now measure each recording's signal-to-noise and clipping first and leave a clean-sounding one as it is, cleaning only the noisy, quiet or clipped ones. Off by default (every recording is still cleaned); turn on "Clean only low quality recordings" under Settings → Transcription → Audio cleanup and set the signal-to-noise floor. What it measured (snr_db, clip_pct) is on the recording in GET /api/v1/recordings/{id} (Cleaning up audio).

  • Kokoro reads aloud on this machine. Pick Kokoro as the voice in Settings → AI assistant → Voice, and spoken answers and podcast hosts are read by Kokoro v1.0 on the server's own CPU, in any of its 54 voices across nine languages, with nothing sent anywhere and nothing billed. It's fetched once picked (the kokoro extra with kokoro-onnx and ONNX Runtime, the model in voice.kokoro_model, full or 8-bit, and the voices: about 450 MB, checked against their SHA-256); podcast episodes wait for it, and the browser reads answers until it's here. A podcast's second host gets a voice in the first host's language. Agents pick it through the settings API and list the voices with GET /api/v1/voice/voices?provider=kokoro (Kokoro).

  • A catalogue of what you select. Library › Export › Catalogue of all selected (.csv) saves one spreadsheet row per asset (title, namespace, collection, kind, date, length, people, tags, language, status, access, origin and a link), with formula-like cells defused (Finding by any attribute).

  • Add providers and make scaling groups in Settings. Settings › Auto scaling lists the providers with their last check in words, Check again, and Remove with Undo. Add a provider picks the kind, then fills a form drawn from the driver's fields (defaults filled, credentials as password fields, Enter saves) and shows the check it runs. New group on a provider asks for a name, the minimum and maximum, a monthly budget when the provider charges, and what each unit is; a group made there is approved as it's made. Rules stay with the assistant, MCP and the API for now (In Settings).

  • A video's own captions. With Settings › Documents › Use a video's own captions on (off by default), a video imported by its address whose captions people wrote takes them as its transcript, in the transcription language or the video's own, so nothing is transcribed (Videos and podcasts).

Sources, sensors, people and agents in the graph. Whoever brought an asset in (the actor of its activity on the timeline: a storage source, a sensor, a person, an automation, a routine, an import hook, or Lens itself) is now a Source node in the graph with an ADDED link to the asset, so (Source)-[:ADDED]->(Recording)-[:MENTIONS]->(Entity) says where Lens learns about each person, place or organisation. The canvas draws sources linked to what their assets mention, the explorer lists an asset's source among its parents, and the Timeline page's Graph button opens the canvas on the sources alone (Actors in the graph).

  • Agents manage the mesh's machines (in progress, as the mesh is). Over MCP and in the assistant, an admin's agent reads the mesh (mesh: its CA, this machine's address, every machine and how it's doing, the join tokens not used yet, never their secrets) and adds a machine (add_mesh_node, which answers with the install line), renames one or changes its namespaces, takes one off and brings it back, and withdraws a join token. In the assistant, adding a machine and taking one off always wait for your yes, and the install line is shown to you once on the approval card, never kept or sent to the model; the rest happens at once. Over MCP every change needs an admin's write-scope token and is audited (Agents).

  • Hugging Face Inference Endpoints as a model host for auto scaling. A Hugging Face group serves a model with vLLM on hardware picked from Hugging Face's list, which also gives its price, and Lens's model calls go to it while it runs, with a key of the unit's own. A unit that never comes up now counts as a failed launch, so three in a row pause the group (Model hosts).

  • Visibility for many assets at once. Owners select assets in the Library and pick Visibility › Public, Restricted or Private; Undo puts each back as it was (access).

  • Send logs straight to Grafana Loki or another HTTP endpoint. Settings → Logs can now POST every line, in batches, to Loki's push API (Grafana Cloud and Alloy too), or as JSON for Vector, Fluent Bit and Logstash, with optional headers for a token, stored encrypted and dropped when the address moves to another server. Off by default, sent from a background queue so a slow endpoint never holds Lens up, with a test line and sent and dropped counts in Settings. Agents use PUT /api/v1/settings/logs and POST /api/v1/settings/logs/test?to=http (Logging).

For you on the timeline. Next to Latest, the Timeline page has a For you order that puts what you care for most first, learned cheaply from your own reactions, comments and marks (important lifts, hidden sinks) summed over each item's source, kind and entities, with a line under each item saying why ("You often react to Work Gmail"). Nobody else's signals touch your order, no model is called, and Latest stays the default. Agents ask for it with order=for_you on GET /api/v1/activities, in the MCP timeline tool and in the assistant (For you).

  • Quiet processing. Settings › Workers › Quiet processing keeps the heavy steps (transcribing, reading and looking through media) to a window such as 01:00-06:00, or to so many at a time, both off by default; the rest runs as before (Background work).

Mute a source on the timeline. An opened item has Mute Work Gmail: everything from that source or person leaves your timeline, with Undo, and a Muted row above the list lifts each one. Muting hides, it never deletes: the activities stay, still trigger automations and still show to everyone else, and hidden=true shows them to you. Agents mute a source or a kind, for good or until a time, with POST /api/v1/activities/mute, the MCP mute_activities tool and the assistant, which asks first after it has read outside text (Muting).

  • Auto scaling's tests hold each provider to its published API. The RunPod, DigitalOcean, Hetzner Cloud and Hugging Face fakes check every request and answer against the provider's own OpenAPI document, and AWS runs on moto, a local stand-in for EC2 (Model hosts).

  • Speech providers are costed in the activity ledger. Give a provider a price per minute of audio (deepgram:nova-3 0.0043 per minute) or per million characters read aloud (openai:tts-1 15 per million characters) in Settings › Telemetry › prices, and each transcription and spoken answer is costed by its length, counting toward budgets like any model call. The prices box also shows and keeps prices per hour and "off" as written, where it used to show them as zero token prices (Activity and costs).

Moving an asset to another namespace and switching a sensor are now activities of their own on the timeline, for people and automations alike: asset.moved is a Move by whoever moved it, with the namespaces it left and joined and the collection it is in now, seen where it is now; sensor.changed is an Update by the person who paused, ignored, resumed or reset a sensor, with the status it had before, seen in the sensor's namespace or by admins when it has none. Saving a status that is already set, or a rename, logs nothing.

  • Amazon SageMaker as a model host for auto scaling. A SageMaker group serves a model with AWS's vLLM container on a real-time endpoint in your AWS account, priced from the AWS Price List, and Lens's model calls go to it while it runs, signed with the provider's AWS key. A SageMaker endpoint can also be set as the LLM provider by hand, with an AWS access key as its key (Model hosts).

  • Storage per source. Admin › System health lists, under Disk, the space each source's assets take and what Lens made of them (frames, pages, reports, caches); GET /api/v1/resources/origins?storage=true gives agents the same (API).

An opened timeline item now shows what happened because of it: the automation runs it started (with their status and a link to the automation) and the approvals those runs asked you for, with Approve and Decline on the ones still waiting, so a pick is made where it was caused. GET /api/v1/activities/{id}/caused and the MCP tool activity_caused read the same for agents, and approvals (API, MCP and the assistant) takes the activity that caused them.

Features

  • schedules: one Schedules page for every routine, automation schedule and the backup (#535)
  • install: LENS_IMAGES=pull installs a release from its prebuilt images (#575)
  • scaling: Proxmox VE provider (#579)
  • automations: lists read each automation's triggers in words, with its runs today and next time (#580)
  • routines: graph workflows find wrong types and noise to tidy (#567)
  • scaling: AWS EC2 units, as spot instances or on demand (#559)
  • routines: graph workflows group topics under broader ones (#594)
  • automations: an Automations page in the web app, with a When, If and Then builder and a test panel (#587)
  • automations: an automation's runs, what's coming up, its variables and costs, in tabs (#600)
  • automations: five starter automations, switched off, and a journey test from an email to a yes (#606)
  • workflows: an HTTP call node behind the web tools' guard (#574)
  • scaling: Linux hosts over SSH as a provider (#589)
  • scaling: RunPod GPUs serve a model as a model host (#614)
  • automations: the builder offers the starters, and the clicks survey makes one and turns it on (#616)
  • scaling: a running model unit serves Lens's model calls (#617)

Fixes

  • automations: a run's trace marks an action that waits as waiting, not done (#586)

0.16.0 October 18, 2026

Features

  • web: SchemaForm, a form generated from a JSON Schema with a variable picker (#566)
  • chat: recover when the model's context is full, and show what a summary covers (#558)
  • pipelines: convert steps that make a video's soundtrack an audio asset and a document's pages image assets (#568)
  • scaling: DigitalOcean and Hetzner Cloud providers (#573)
  • workflows: draw an automation's workflow on the canvas, with action and decide nodes (#576)

0.15.0 October 18, 2026

  • The assistant sends mail, adds events and writes files, after your yes. New tools send_mail (send, or save a draft, from Gmail; reply_to keeps a message's thread), add_event (an event on Google Calendar, inviting people if asked) and write_file (a text file on a storage connection, replacing one only when asked). Each always waits for your approval, which now shows all of it first: the whole message with who it goes to, the event, the file's text. A Gmail or Google Calendar connection only reads until an admin picks "Also sends mail and saves drafts" or "Also adds events" under The assistant in its settings and connects the account again, since Google is then asked for gmail.compose or calendar.events; Google Drive connected read only is never written. Every change is in the audit log (Mail, calendars and files).
  • The upgrade agent starts the code sandbox's gateway. Settings → Code sandbox → Run the gateway on this machine now works on installs with an upgrade agent: the agent knows the sandbox profile and starts or stops its three containers, as it does the MCP runner, the browser and monitoring. An older agent still says to run the line by hand until it has updated itself (The gateway).
  • Deep research. Give the assistant a goal (/research <goal>, research: true on a question, POST /api/v1/chats/<id>/research, its start_research tool, or MCP) and it plans the research: up to three follow-up questions, the sub-questions, where it looks (the archive, the graph, notes, the web, the code sandbox), the tool calls it may make, the report's outline, and a labelled cost estimate. Approve the plan as it is or edited, revise it, or say "yes" in a chat room; an API key or an app only declines it unless research.agents_skip_review is on. It runs in the background, one run per person at a time, within its tool calls, research.max_minutes and your budgets, survives a restart, and stops when asked; the cited report comes back in the conversation, its claims checked against their sources, and saves as a note or makes a podcast episode. In the web app, the composer's Deep research switch asks for it; the plan is a card to edit and approve with Enter, then a progress card that survives a reload; and the report saves as a note, reads aloud, makes a podcast or exports as Markdown. Settings → AI assistant → Deep research holds its limits (Deep research).
  • Read any message aloud. Every question and answer in a conversation has a speaker button: one click reads it aloud by the speech model and voice picked in Settings → AI assistant → Voice (else the browser's own speech), another stops it. One message plays at a time, long answers go in pieces under every provider's limit, and each piece's audio is kept in the page, so playing it again doesn't call the model again. Each call is logged in the activity ledger (Read a message aloud).
  • Conversations title themselves. After a conversation's first answer, the model gives it a short title from the question and the answer, with the model for summaries (ai.compact_model) when one is set, in one call counted for the conversation in the activity ledger; until then, and when there's no model, it's titled with the first question's words. A title someone gives it is kept. title_by says who titled it, POST /api/v1/chats/<id>/title and the MCP tool rename_chat (without a title) have Lens title it again, and rename_chat with a title renames it. Settings → AI assistant → Title conversations (on) turns it off (Titles).
  • More tests for automations' conditions. An If, a trigger's where and a condition node can now test that text starts or ends with something or matches a pattern, that a value is in a list, that a number or a time is between two, before or after a time or within a duration, falls on certain days or times of day, and whether a value changed since the last activity. Each value is checked when it's saved, and patterns that could tie up the worker are refused (Operators).
  • Automations look back. An automation's If can ask whether something like this happened before: activities of some kinds within a time, from the same sender or about the same thing, at least or at most so many times, looked for only in the automation's namespaces and only as its person may see them. changed in a trigger's where or an If compares each activity with the last one, and trying an automation keeps nothing (Looking back).
  • Automations wait for their triggers together. An automation's join runs it when all of its triggers came within a time (for the same sender, say), when one came a number of times, when one didn't come in time after another (no reply within two days), or once for a batch collected over a time. What it waits on outlasts a restart, and switching the automation off drops it (Joins).
  • Secrets from Vaultwarden. A secret field in Settings (a model's API key, a chat app's token, a mail password) can refer to an item in a Vaultwarden or Bitwarden vault, like vaultwarden://OpenAI/password, instead of holding the secret. Lens reads the value, keeps it in memory for secrets.cache_minutes (10 by default) and reads it again in the background, so a key changed in the vault reaches Lens by itself. Only the reference is stored, and it's checked against the vault before it's saved. Lens signs in as an account of its own and reads one folder of it (secrets.vaultwarden_folder, "Lens"). Settings, the API (GET /api/v1/secrets, GET /api/v1/secrets/items, POST /api/v1/secrets/test), the MCP tools list_secrets, list_vault_items and set_secret_reference, and the assistant's list_secrets and vault_items show references and whether they could be read, never a value. The client (python-vaultwarden, Apache-2.0) is fetched once a Vaultwarden address is set. Secrets saved in Lens stay the default. In the web app, Settings › Secrets sets the vault up and tests it, and every secret field gets From Vaultwarden, a picker of the vault's items that fills in the reference (Secrets).
  • A decide node for judgement calls. An automation's workflow can ask the decision model a question with a few options ("Does this need me today?") and go on along the option it picked, or along unsure when it isn't sure enough, none is set up or the budget is spent. The email it reads is marked as outside text, the answer is kept per activity so a retry doesn't pay again, and the call shows in the activity ledger under the run (The decide node).
  • Specs from transcripts. Turn a voice session or a meeting into a technical spec, saved as a note linked to it: the model writes the spec, then lists every named decision, every "no" and "not that, this", every constraint and every gap the speaker left to decide later, each with its quote and a link to the moment it was said; a quote the transcript doesn't hold word for word is marked. The prompt is the "Spec from transcript" template, editable on the Templates page. In the web app it's Write a spec, then Open spec, in an asset's More actions menu, and a Spec step in the pipeline editor. Agents write one with POST /api/v1/recordings/{id}/spec, the MCP tools write_spec and get_spec, and the assistant's write_spec (which asks first); pipelines and batches run it as a spec step (Specs from transcripts).
  • MQTT through Remote access. Devices away from home can publish to the sensor hub, and subscribe to it, at the web app's own address: MQTT over WebSockets at /mqtt (wss:// under HTTPS), through the Cloudflare tunnel and HTTPS too. It's off until an admin turns on MQTT at the web app's address too in Settings › Sensors, and there every client signs in with a hub login, with too many wrong passwords from one address turned away for a while. It's the same broker as on port 1883. SMB, NFS and syslog stay on the local network, and Remote access now says why (Sensors, Remote access).
  • Passkeys at your remote address in one step. A passkey works only at the address it was made at, so one made at localhost or on the network didn't work at the Remote access or HTTPS address, and getting one there took a sign-in link from email or an admin. Now Profile and sign-in › Passkeys lists Lens's other addresses with Open signed in, which opens one with a 15-minute sign-in link of your own, where one fingerprint adds a passkey for it (Authentication).
  • Files added over NFS are imported. A file copied into a collection's folder on the NFS share is imported a moment after it's closed, as the account the share shows the tree as (nfs.account), with the same checks as an upload. rclone keeps files being written in a cache on Lens's disk and puts them to Lens once they're closed; a file Lens can't take shows in the share's log. Import files people add in Settings, NFS share (nfs.add_files, on) turns it off and serves the share read-only again (Adding files).
  • A private network between Lens's machines (in progress). Lens is the certificate authority of a Nebula mesh between the machines it runs on: an admin adds a node with a join token (POST /api/v1/mesh/tokens), the machine joins without ever sending the token (POST /api/v1/mesh/join: an HMAC proves it, the answer comes back sealed with a key derived from it) and gets an address, a certificate naming its role and namespaces, and its Nebula config, whose firewall lets worker nodes reach the database and every node the API, and nothing else; a node renews, reports and refreshes its config with its own node token, and removing one blocklists its certificates on every machine. Off until a node is added; settings in mesh. Nebula running on the primary, the install line that joins a machine and the Settings pages follow in their own pull requests (Nodes and the mesh).
  • Export a conversation. GET /api/v1/chats/<id>/export.md, .pdf or .json gives the version showing, one message (?message=) or, as JSON, every version (?all=true). The Markdown has each question and answer, its citations as footnotes linking back to the moment in Lens and the tools it used; the PDF is that Markdown printed by Chromium, else anytopdf, else LibreOffice (409 when none can run); the JSON is OpenAI's chat message format in a tree with Dublin Core terms, or ?format=messages for one {"messages": [...]} line, checked against a published schema. Agents use list_chats, read_chat and export_chat over MCP. In the web app, an answer's copy button copies it as Markdown or plain text and exports it, questions and code blocks have their own copy buttons, and the conversation's header has Export; without a PDF printer on the server, the browser prints it instead (Copy and export).
  • Files added over SMB are imported. Copy files into a collection's folder on the SMB share and each is imported when it's closed, as an upload is; a new folder under Collections is a new collection. Files of a type Lens doesn't take are refused as they're made, files over the upload limit are cut off as they're written, and a file deleted as it's closed or left open when its client goes away isn't imported. The system's own files (.DS_Store, Thumbs.db, Office's ~$ files) are kept aside, and renaming a file saved under a temporary name imports it. Import files people add in Settings, SMB share (smb.add_files, on) turns it off and makes the share read-only again (Adding files).
  • News feeds. Subscribe to a blog's or a news site's RSS or Atom feed: add it on the Sources page (its address, or just the site's) and watch it, and each new post comes into a namespace as text, with its link, author and date, so answers cite it. Feeds are asked for again only when they've changed, and a private feed's address is kept like a password (News feeds).
  • Automations on feed posts. Each new post of a news feed is a feed_post.received activity with its address, site, feed, author, date and categories, so an automation can start on posts from one feed, author or category (Kinds).
  • Agents follow news feeds. subscribe_feed (MCP and the assistant, admins) follows a feed into a namespace in one step, checked before it's kept; news_feeds lists them and unsubscribe_feed stops one (News feeds).
  • Fixed: Lens could freeze after an answer. When a browser left a streaming answer early, the cleanup of that stream could wait for a database connection its own thread already held, and every request after it waited too. A query made inside another on the same thread now runs on that connection (Concurrency).
  • Mail, calendars and files in chat. An admin's assistant can search and read the mailboxes, calendars and storage they connected under Settings → Sources, through the new tools connections, search_mail, read_mail, calendar_events, list_files, find_files, read_file and import_from_connection; the tool picker shows them as "Mail, calendars and files". Gmail is searched with its own query language and IMAP by words in the newest subjects and senders; Google Calendar gives the events of a window; files are found by name and read when they are text, PDFs or Word files. Answers cite a message or an event with a link that opens it, and bringing things into Lens waits for the person's yes (Mail, calendars and files).
  • The decision model types the names the rules can't. When Jev or Laya is set up, analysis asks it what each new Term of a recording is (a person, an organisation, a product...), with the lines it's said in, in one call per recording, and keeps the types it is sure of. Settings › Analysis › Names the rules can't type (analysis.typing) can also let the language model answer, or turn it off; the language model is never asked by default (Names the rules can't type).
  • A loading bar, as in Gmail. A thin blue bar runs along the top of the web app while a page or its data loads: on every page change, and while a page waits for its first answers from the API. Refreshing what's already on screen and anything quicker than a blink don't show it (The web app).

  • Published feeds. A namespace publishes a private RSS feed, or a podcast feed with Apple Podcasts' tags, that any feed reader or podcast app follows at its address: resources (with their audio as the enclosure), podcast episodes once they're made (with the sources they cite), timeline activities and text. A feed holds only what everyone who reads its namespace can already read, never a vault's; its token is all a reader needs, so it's kept sealed, a new one stops the old address at once, and the feed asks directories and search engines not to list it. Editors make and fill feeds with POST /api/v1/feeds and POST /api/v1/feeds/{id}/items; agents use the MCP tools list_feeds, create_feed and publish_to_feed (Published feeds).

    • Routines fill them: the graph workflow nodes Select assets and Select activities pick what came in since the routine last ran, Publish to feed adds it to a feed, and Podcast episode makes an episode about it (added to a feed, listed once it's made). Try on the canvas says what they would do (Publishing from the graph).

Features

  • assistant: answer questions about Lens itself from its docs and this server (#400)
  • automations: an index of triggers for the dispatcher (#545)
  • chat: summarise long chats at the model's real context window (#528)
  • scaling: auto scaling groups, rules and approvals, with This machine as the first provider (#556)
  • workflows: an entity filter node (#562)

Fixes

  • domain: let automation_nodes and events_content be imported first (#523)
  • settings: test the LLM address and model on screen, not only the saved ones (#539)

0.14.0 October 17, 2026

  • Dates read as days, for agents. With Settings → Analysis → Read dates on (off by default), analysis reads the dates each line gives as days on the calendar: "next Tuesday" in a recording made on a Wednesday is the Tuesday after, "last week" Monday to Sunday before, "March 2024" all of March, worked out from the asset's own date. Agents ask what was said about a day or a range with GET /api/v1/dates, the MCP tool list_dates and the assistant's find_dates, each date with its line and a link to that moment. English rules, no model; a weekday alone or "10/11/2026" is left out rather than guessed (Dates).
  • Choose the tools, a limit and the namespaces of an answer. A conversation, or one question, can pick the tool groups or tools its answers may use (tools), cap their tool calls (max_tool_calls, up to the new Settings → AI assistant → Most tool calls a question may ask for, 50 by default) and narrow one answer to its own namespaces (scope). GET /api/v1/chats/tools lists the groups a person has, what each tool does (reads, asks first, sends data out) and why a group is missing. An admin's switches still win, and new conversations start with the person's last choice. In the web app, a Tools chip beside the scope picks them, and "All my namespaces" tops the scope's list (Choosing tools, namespaces and a limit).
  • Names get their real types, not Term. The built-in extractor now tells people, places, organisations, events, works and dates apart without a model: by a title or role before a name, a given name, the word a name ends or starts with (Corp, University of, Summit, Act, River), lists of countries, regions and large cities and of well-known companies and products, what a name is called ("a company called Lumen") and the namespace's named speakers. Only names nothing tells stay Terms, and "and" no longer joins two names into one. Existing entities analysis left as Terms are typed once on upgrade, keeping every type people set; the change is in the graph's history to roll back (How a name gets its type).
  • Entities found by spaCy, fetched when picked. Settings → Analysis → Entity extraction → spaCy now fetches spaCy (MIT) and its small model the first time an asset needs it (about 60 MB): English, German, or one for many languages. The defined entities still come first, a lone word like "Okay" isn't taken for a name, and until spaCy is there the rules find them and the job log says so. Workflows' Extract entities (rules) node uses it too (Entities and the graph explorer).
  • Punctuation put back in transcripts. With transcripts cleaned up, Settings → Analysis → Put punctuation back adds full stops, commas and question marks with a local model (kredor/punctuate-all, MIT, 12 languages), to transcripts that have hardly any (imported captions) or to every one, keeping the marks they have. The model and PyTorch (about 2 GB) are fetched the first time it's needed; off by default (Cleaning up transcripts).
  • Clean up transcripts before analysis. With Settings → Analysis → Clean up transcripts before analysis on (off by default), a new Clean up step runs before Analyze: a word said three times in a row is kept once ("I I I looked" → "I looked"), doubled pronouns and articles once, "um" and "uh" go, and spacing, "i" and sentence capitals are fixed, by rules, on English lines. Every changed line is in Change history with Revert, and lines someone corrected are left alone. Reprocess, pipelines, POST /api/v1/recordings/<id>/reprocess and lens clean run it (Cleaning up transcripts).
  • Describe pictures on this machine with Moondream. Settings → LLM provider → Describing pictures can also have M87 Labs' Moondream caption each page and shot (llm.describe_engine moondream), through transformers and PyTorch, so no picture leaves the machine: Moondream 3 Preview (llm.moondream_model moondream3-preview, about 19 GB, an NVIDIA GPU or a Mac with 32 GB; BSL 1.1, not open source) or Moondream 2 (moondream2, Apache-2.0, about 3.7 GB, on a CPU, slowly). It's fetched, pinned to a revision, the first time a job waits to describe something, and only where the chosen one can run; elsewhere the step says why. Agents choose it with set_picture_descriptions (Descriptions).
  • Describe pictures on this machine with Florence-2. Settings → LLM provider → Describing pictures can now have Microsoft's Florence-2 (MIT) describe each page and shot instead of the provider's model that can see images (llm.describe_engine florence-2). It runs on the CPU through ONNX Runtime, so no picture leaves the machine and no provider is needed; its 8-bit export is fetched the first time a job waits to describe something, pinned and checked against its SHA-256: base (about 280 MB, llm.florence_model) or large (about 830 MB). Each picture is a model.describe row in the activity ledger. Agents read and change it with the MCP tools picture_descriptions and set_picture_descriptions. The default stays the provider's model (Descriptions).
  • Automations run. An automation saved over the API (/api/v1/automations) now reacts to new assets in its namespaces: when its If passes, it runs its workflow as the person who made it, within its limits and budgets, and each run shows what its actions did. Try one on an activity without keeping or sending anything, or run it now; Settings → Automations switches them all off and sets how far they may go (Running automations).
  • Each answer says what produced it. The i in an answer's header shows the model and the host it ran on, the tools it was offered and used, the scope, its limits and what it used, tokens, cost (an estimate from your prices), how long it took, how it ended and which tool results read like instructions to an AI, with the calls made for it. Agents read the same record over the API (GET /api/v1/chats/<id>/messages/<mid>), and each tool step shows how long it took. Answers from before have no record (What each answer records).
  • Download notes as Markdown. A note's ⋯ menu in the tree downloads it as a Markdown file, and the Notes page's ⋯ menu downloads all of a namespace's notes as a zip in folders that follow the tree, for Obsidian and other notes apps (Downloading as Markdown).
  • Templates. Notes inside a namespace's Templates note are templates: New note from a template… on the Notes page and in Jump to starts a note from one, with its text, drawings, files and filing. Any note's menu in the tree can also Make a copy (Templates).
  • A note says where it is. Above a note's title, Notes › namespace › each note it's inside, every one a link; under its text, Inside this note lists the notes inside it with Add a note inside. The text now starts in line with the title, the word count shows next to Saved, and Pin, History, Download and Delete are in the note's ⋯ menu. Costs and activity is folded at the bottom (On a note).
  • Automations for agents. Agents list, save, try and run automations through the MCP tools automations, automation_actions, save_automation, test_automation and run_automation, with problems in plain words. The assistant saves them switched off and asks before switching one on or running it (Agents).
  • File shares through Remote access. S3 and WebDAV can now be reached at the web app's own address, /s3 and /dav, so they work through the Cloudflare tunnel and HTTPS as well as on the network, and on Cloudron, which publishes only the web app. It's off until an admin turns on At the web app's address too in Settings › File shares; then a new access key's dialog gives those addresses. Signing in is the same: a person's own access key, read-only. MQTT, syslog, SMB and NFS aren't HTTP and stay on their own ports (The graph as files, Remote access).
  • Files added over S3 and WebDAV are imported. Copy a file into a collection's folder (rclone copy, Finder, Windows Explorer, any S3 client) and it's imported there as an upload is, with the same checks and the namespace's pipeline; a new folder under Collections is a new collection, and a file anywhere else goes to the namespace's default collection. It takes the import permission (or an editor's role on the collection), files the system writes for itself (.DS_Store, Thumbs.db, half-written files) are never imported, and a re-sync copies nothing twice. The S3 server now takes streamed and multipart uploads; WebDAV takes PUT, MKCOL and Finder's and Explorer's ways of copying. This is on whenever the shares are; Import files people add in Settings, File shares (shares.add_files) turns it off and makes them read-only again (Adding files).
  • The web clipper takes your API token, or just signs in. It used to take only an import webhook's token, so an API token was refused with "Lens didn't recognise this token: make a new import webhook and paste its token". Now its options have Sign in: Lens asks you to allow the clipper, nothing to copy, and clips go into the namespace you pick (the popup offers each one you can add to). Use a token instead takes an API token with write access or a hook's token, and when Lens refuses one the clipper says why: unknown, revoked, read-only, or a Lens too old for API tokens. Anything acting as you (an agent, a script) can push to POST /api/v1/hooks/import?namespace= with your own token too, and GET there says where a token's pushes go (Web clipper, API).
  • Versions in conversations. A conversation is now a tree: editing a question makes a new version beside it instead of deleting what followed, regenerating an answer (regenerate, with the same model or another) writes one beside it, and a question can go on from any message (parent). GET /api/v1/chats/<id> shows each message's versions and every version's messages, and PUT /api/v1/chats/<id>/head switches between them; the model reads only the version showing. In the web app, Regenerate (and Regenerate with… another model) replaces Try another model, and ‹ 2 / 3 › under a question or an answer moves between its versions. Existing conversations are upgraded on the next start (chat-tree) (Versions).
  • The assistant searches the web. With web search on (off by default; Settings → AI assistant → Web search), the assistant gets web_search and web_read: it searches with DuckDuckGo (no key), your own SearXNG, Brave Search or Tavily, and reads pages and online PDFs, public addresses only, through the same guard as captured pages. Pages are cited [n] like excerpts, with their site, and open in a new tab; each search and read is in the activity ledger, and once an answer has read outside text, one it chose itself asks first (Web search and reading pages).
  • Add an MCP server with a form, or paste its config. Extensions → Add MCP server (admins) takes an address and its headers, or a command with its arguments and environment, or reads a config pasted from Claude (.mcp.json) or VS Code, one server or many; Test lists its tools before it's saved. Tokens and keys in headers and env are now sealed and never shown again (manifests say ••••), and an upgrade step seals the ones saved before. Each server's tools have a switch and "Asks first", kept in its manifest (Adding an MCP server).
  • A browser for the assistant, and ready-made MCP servers. Add MCP server now starts with Ready-made: Browser runs a headless Chromium (Playwright MCP) in a container of its own, whose only way out is a proxy to public addresses on ports 80 and 443, and adds it with one click; GitHub fills in GitHub's own server with a field for its token. Values written as ${NAME} in a pasted config now get a field of their own too. The MCP runner, the browser and monitoring turn on and off from the web app (the machine's upgrade agent starts and stops them), and agents do the same with PUT /api/v1/admin/services/<profile> or the MCP tools services and set_service. A server whose spec says keep: true keeps one session between calls (The browser, Optional containers).
  • MCP servers in Settings, and for agents. Settings › AI assistant now lists the MCP servers the assistant calls, each with how many of its tools are on and a link to its page, next to Add MCP server and the switches for the MCP runner and the browser. Agents list them with the MCP tool mcp_servers and add one, ready-made or their own, with add_mcp_server (an admin's API key with the write scope) (Adding an MCP server).
  • Describe an automation in words. POST /api/v1/automations/draft asks the model once to turn a sentence into a draft automation to try and save, and says what didn't fit and what it couldn't express (Describe it in words).
  • The assistant runs Python and bash. Turn on Settings → Code sandbox and point Lens at an NVIDIA OpenShell gateway, and the assistant gets run_python, run_bash, sandbox_files and import_sandbox_file: code runs in a sandbox of the conversation's own, with none of Lens's data or keys and no network unless you allow the package registries, or those and hosts you list. Files attached to the conversation go in; a file it makes reaches a namespace once the person approves. Off by default (Code sandbox).
  • Start the code sandbox's gateway from Settings. Settings → Code sandbox → Run the gateway on this machine has the upgrade agent start NVIDIA OpenShell's gateway next to Lens (the Compose profile sandbox) and turns the sandbox on; Lens signs in to it over mutual TLS. First it checks the machine can run it (the Docker Compose install, a 64-bit machine, Landlock in its Linux kernel) and says why not. Off deletes every sandbox and stops it. The same over PUT /api/v1/admin/sandbox and the MCP tools sandbox_gateway and set_sandbox_gateway (The gateway).
  • See the code the assistant ran, and download the files it made. Each Python or bash run is a folded step under the answer that opens to the code, its output and how it ended. With share_sandbox_file the assistant hands you a file the sandbox made, a chart or a converted document, as a download button on the answer; Lens keeps it encrypted with the conversation and deletes it with the conversation (In the conversation).
  • Find anything by who, what and when. Agents and the API can find assets by the faces seen in them, the people heard, the entities they mention, the topics they're about, their kind and content type, when they were made (a day, a month or a year), tags, language, objects seen, emotions heard, length and words in them, together or alone, naming people and things instead of looking up ids. Each result says why it matched, with moments to cite (POST /find, the MCP tool find, the assistant's find_resources; Finding by any attribute). The Library list takes faces, entities, topics, content types and emotions as filters too.
  • Classify an asset or a text. Agents and the API can ask which of some labels fits, how sure that is and every label ranked: labels they give, Lens's content types or a namespace's topics. The decision model answers, else the language model, logged in the activity ledger; with apply, an editor's sure answer sets the content type, suggests the topic or adds the tag (POST /classify, the MCP and assistant tool classify; Classifying).
  • Classify in workflows. A Classify node on the workflow canvas picks one of your labels, a content type or a topic for the asset (or for what came in) and passes on the choice, how sure and every label ranked, for a Condition or Switch to branch on; set to act, a sure answer sets the content type, suggests the topic or adds the tag (Workflows and the canvas).

Features

  • automations: the variable picker's catalog and a template's preview for a draft automation (#461)
  • automations: the Approvals page, its badge, and Home's row (#459)
  • automations: save automations for their maker, with checks and an API (#451)

Fixes

  • assistant: let the assistant's tools be imported first (#466)
  • files: let the metadata module be imported first (#475)
  • worker: log the database upgrade a worker runs as it starts (#496)
  • ci: the upgrade check counts entities of every type (#512)

Dependencies

  • Bump the python-minor group across 1 directory with 11 updates (#478)
  • bump the javascript-minor group across 1 directory with 16 updates (#381)
  • update fastapi[standard] requirement in /fastapi_backend (#367)
  • bump the javascript-minor group (#500)
  • bump transformers (#503)

0.13.0 October 16, 2026

  • Updating Lens in a microsandbox on a Mac gets past "Building Lens". An update after a build that stopped partway stopped there with rm: cannot remove ... Directory not empty, then tar: /data/.lens/backend: Cannot open: the Mac's file sharing can list a folder as empty while a file is still in it, so removing the earlier build's node_modules didn't always get through. The sandbox now tries again and moves what's left aside, and builds the web app on its own disk instead of in the volume. Run the install line again to update (microsandbox).

Features

  • automations: match trees for conditions (#425)
  • automations: stream of activities with kinds, an event log and following (#426)
  • automations: checks for automation shapes and an action registry (#427)
  • automations: read the stream of activities over the API, MCP and the assistant (#428)
  • settings: an Automations section for the stream of activities (#429)
  • timeline: mark activities reviewed, important or hidden (#432)
  • automations: tell a person in their own chat app, the notify action (#433)
  • automations: ask the model, use an assistant tool, ask the agent (#438)
  • automations: new assets as emails, calendar events, files, uploads, web pages and imports (#435)
  • timeline: a Timeline page to review what came in, and Latest on Home (#437)
  • automations: call a web address, with credentials sealed in headers (#439)
  • automations: finished runs, deletions and calendar moves as activities (#440)
  • automations: run a workflow, a pipeline or a routine (#441)
  • automations: comments, notes and graph changes as activities (#443)
  • automations: set a field, move to a collection, write a note (#444)
  • automations: a webhook address per automation trigger (#442)
  • automations: sensor readings, alert patterns and new devices as activities (#445)
  • automations: timers that outlast a restart and fire once (#446)
  • automations: what people say to Lens as chat.message activities (#448)
  • workflows: an event scope for automations, with action and decide nodes (#436)
  • automations: routine runs, budget warnings, backups and releases as activities (#449)
  • automations: schedule, at and relative triggers as timers (#450)
  • automations: actions that wait, carried out at their time (#452)
  • automations: an automation's action that asks first waits as an approval (#447)
  • automations: decide an automation's approval with a yes in your chat app (#455)
  • automations: approvals as activities and an approval.waiting notification (#457)
  • automations: agents read and decline approvals, and never approve (#458)
  • automations: templates in params and prompts, with outside text kept marked (#454)
  • automations: saved variables, kept between runs and set by the set_variable action (#460)

0.12.0 October 15, 2026

  • microsandbox runs on Debian 13 too. Its sandbox now starts from python:3.12-slim-trixie, like the backend image, so a local model runs there on a Mac with Apple silicon. The packages kept from the earlier Debian are cleared on the next start (microsandbox).
  • A local model's server asks for its key again. llama.cpp's current builds read the server's API key from a differently named variable, so the server Lens started for a local model answered without one (to other containers on the same Docker network, not from outside the machine). Lens now sets both names, and stops the server, saying why, if it answers a request without the key (Local models).
  • A local model runs on arm64 in Docker (a Raspberry Pi, Docker on a Mac). The backend image is now built on Debian 13 (trixie) instead of Debian 12, as llama.cpp's Linux arm64 build needs its newer system library (glibc 2.38). Upgrading rebuilds the image; nothing else changes (Local models).
  • A local model starts again. llama.cpp's versioned releases (v0.6.0 on) carry only the source, so Lens found no llama.cpp build to fetch and a model picked under Settings → Local model didn't run (llama.cpp v0.6.0 has no build for linux arm64). Lens now takes the build that release names, and says why when the build it fetched can't start on this machine (Local models).
  • Today's note. Today on the Notes page, or Today's note in Jump to, opens the note for the day, made the first time it's asked for inside a Daily notes note (Today's note).
  • Pin notes. A note's ⋯ menu in the tree has Pin: pinned notes show at the top of the tree and first on the Notes page, for the person who pinned them only (Pinned notes).
  • Notes tree menu. Each note in the tree in the left navigation has a ⋯ menu to add a note inside, rename it in place, move it (find the note to put it in by name, or move it to the top) and delete it with Undo, so nothing needs dragging and it works on a phone. The navigation links and the tree now scroll together, so a long tree has room (The tree).
  • Delete a note with Undo. Deleting a note no longer asks first: the notice has Undo, which puts it back where it was. Deleted notes wait in Recently deleted, at the bottom of the Notes page, for 30 days with their files and history, to put back with one click or delete for good; then they go on their own (Deleting and putting back).
  • Connect Gmail with Google. Add connection › Gmail now has the Google account and Service account choice and Connect with Google, as Google Calendar does: Google asks to read mail (read only; all of Gmail to read over IMAP), the account's address becomes the connection's, and its labels open to pick one to watch. A service account takes its key and the address it reads, and says which client ID and scope a Workspace admin allows. A Gmail connection whose sign-in stopped working has Connect Google account on its row (Gmail and Google Workspace).
  • Connect with Google. A Google Calendar connection signs in to a Google account from a button now: Google's consent screen asks for read-only calendar access and sends you back to Sources with the account connected and its calendars open. It uses the Google sign-in provider's OAuth client, an earlier connection's, or one entered in the form, which also shows the redirect URI to register. A connection whose sign-in stopped working has Connect Google account on its row; pasting the authorized user JSON still works (Google Calendar).
  • Google Drive connects with Google too. When Lens has a Google client (Google sign-in's, or an earlier connection's), a Drive connection signs in with Connect with Google in one click and shows the account it is connected as; Read only asks Google for read-only Drive access. With no client it signs in through rclone, as before, or through a Google app of your own (Google Drive).
  • Every entity has its own page. An entity's page is named by its name, as a wiki names pages: /wiki/<namespace>/<Name> (spaces as underscores), so Ada Lovelace in work is /wiki/work/Ada_Lovelace. It shows what the entity is, the notes people and the assistant keep on it in the BlockSuite editor, every line that says it, and the assets, people and entities it comes up with. Its name on the Entities page now opens it (the rest of the row still opens the side panel), as do @ links in notes, the graph panel and the entity's RDF URI. Any case, a name it had before a rename, or the name of one merged into it opens it too (Entity pages).
  • The update notice no longer pushes pages off the screen. While Lens vX is out shows above them, Chat, the graph and a recording's page are that much shorter, so a phone's question box and a recording's player stay in sight.
  • An MCP server for each namespace. A namespace's owner can turn on its own MCP server under Admin → Namespaces → the namespace → Connect apps, which copies its address (/mcp/<namespace>). An agent connected there has the same tools but sees that namespace alone, whatever else the person who signs it in can read, and a token an app gets for it opens nothing else in Lens. Off by default (One namespace's MCP server).
  • Chat completions for any app, from one namespace. A namespace can answer OpenAI's chat completions API at /api/v1/namespaces/<namespace>, so any app that speaks it (with a Lens API key and the model lens) gets answers from the configured model, grounded in that namespace and citing it: [n] with a Sources list, citations and lens.sources, streamed or not. Its owners pick the read tools the model may use there (none by default: one search, one model call). Calls count for the namespace's budget. Off by default; turned on under Admin → Namespaces → the namespace → Connect apps (Chat completions for any app).
  • API keys for one namespace. A new API key can be made for one namespace (Opens, on the API tokens page, which Connect apps fills in). It works on that namespace's own MCP server and chat completions, with your role there, and is refused everywhere else in Lens, so an app given it sees nothing more (API tokens).

0.11.0 October 15, 2026

  • Installing an update from the web app works again in a microsandbox. Back up and install stopped at the backup with Permission denied: '/data/.lens/apt/lock', and so did every backup there, because backups had started keeping everything under /data, including the Lens the microsandbox runs (.lens). Backups now keep only Lens's own files: hidden folders at the top of data_dir, and SurrealDB's own folder (the backup's export already holds the database), are left out, and a file Lens can't read is left out with a warning instead of stopping the backup. When backing up stops an upgrade, Settings now marks Back up the database as the step that failed (Backups).
  • The Ask button no longer covers buttons. The floating Ask button at the bottom right used to sit on whatever was there, such as Import on the Import page. Now, when a button or field is under it and scrolling can't move it clear (a row of buttons pinned to the bottom of the screen, or the last button on a page scrolled to its end), the Ask button rises above it, and steps out of sight when there's no room to rise. Dialogs, drawers and toasts drawn over it don't move it. The clicks survey now fails a journey whose control the Ask button covers, and checks every page for it (Chat on any page).
  • Google Calendar as a sensor. Add a Google Calendar connection on the Sources page (or POST /api/v1/sources with type gcal): each calendar is a folder and each event becomes text, as an iCal feed's do, read-only through Google's Calendar API. It signs in as a Google account (an OAuth client's authorized user JSON) or as a service account, which reads the calendars shared with it or, in Google Workspace, a user's calendars through domain-wide delegation. Calls to Google show in the activity ledger (Google Calendar).
  • Gmail and Google Workspace as a sensor. A new kind of email source reads a Gmail or Google Workspace mailbox through Google's Gmail API (read only) or over IMAP. It signs in as a Google account (its authorized user JSON, as for Google Calendar), or as a Workspace service account with domain-wide delegation, so nobody has to sign in. Labels are its folders, and a watch follows Gmail's history: it reads only what arrived, and one that leaves what's there reads nothing old. Connecting the account from the connection dialog is in progress (Gmail and Google Workspace).
  • Find a note. The Notes page has a search box that finds notes by the words in their title, summary or text (the pages of recordings and entities too), with the words around each match marked, and Enter opens the first. A filter shows only Projects, Areas, Resources or Archive, and each note says which notes it's inside. Jump to (⌘K) finds notes as well. GET /api/v1/notes/search; find_notes gives agents the words around each match (Finding notes).
  • Paste a Drive folder's link. A Google Drive connection's Root folder and shared drive take the link from the browser's address bar as well as the bare id, so limiting imports to one folder is a paste, not hunting for the id.
  • Sign in to Dropbox, OneDrive or Google Drive with no app of your own. Their connections now have Sign in with Dropbox (Microsoft, Google): the service's page opens in a new tab, Lens runs rclone authorize on its own server, and you paste back the address the tab ends on, or nothing at all when the browser runs where Lens does. No rclone to install on another computer, and no Google Cloud app needed for Drive. Pasting a token still works. Agents do the same through POST /api/v1/sources/rclone-signin (Storage sources).

0.10.0 October 15, 2026

  • The assistant always answers. When a question used up its tool steps (ai.max_steps), the assistant used to stop with "I ran out of steps before finishing". Now it's asked to answer from what it found, with the tools off, and says what it didn't get to check; a reply with nothing in it is asked again the same way. It also knows its step budget up front, so it looks things up in fewer rounds, and the same lookup twice in one answer points it at the result it already has instead of running it again (The assistant's tools).
  • Backups hold Lens's files too. Each backup now also keeps the files under data_dir (uploads, objects, media, frames, renditions, web pages, reports, exports, podcasts, certificates), and restoring one puts them back. Files that haven't changed since the previous backup are hard links, so a backup takes only the room of what changed; the first one takes as much room as the files. Downloads and copies on a storage connection carry them as well. Older backups show database only (Backups).
  • Analytics. A new Analytics page (admins) shows active people per day, week and month, stickiness, churn rate, retention by sign-up week and feature use, worked out on the server from the activity ledger and sign-ins. Each day Lens also keeps a few counted events (install, daily active, feature used, upgrade, inactive, return). They can optionally go to your own Google Analytics 4 property too; that is off by default and is set in Settings → Usage analytics or the setup wizard (Usage analytics).
  • Merge entities in fewer clicks, and undo instead of confirm. On the Entities page, tick rows and the bar at the bottom merges them into one (the one on the fixed list, else the most mentioned) or gives them one type. An entity's side panel has Merge, a find box where Enter merges the entity picked into it, and Maybe the same, its likely doubles with Merge and Not the same. Renaming an entity to another's name offers to merge them. Merges, type changes and hiding say what happened with Undo (Entities).
  • Edit and merge from the graph. In the graph explorer, an entity's or topic's panel has Edit, which opens the same editor as its own page, and an entity's has Merge and Maybe the same. The node menu has Edit… and Merge into another…, which merges the node into the one clicked next (entities and speakers of one namespace; entities of two namespaces are linked as the same thing), each with Undo (The graph).
  • Undo topic merges and deletes. Merging or deleting a topic happens at once, with Undo on its notice instead of a confirm step: the topics come back with their labels, recordings, broader and related topics and pages. A topic's drawer has a find box to merge another topic in, and topics merge on the graph canvas too. The API answers a merge or delete with an undo id (POST /api/v1/topics/changes/{id}/undo) and lists recent ones (Topics).
  • Edit entities from a recording. In a recording's Entities tab, the pencil next to an entity opens its side panel, so editors rename, retype, hide or merge it without leaving the recording (Entities).
  • Agents edit and merge entities and topics. The MCP server has edit_entity, edit_topic, merge_topics, delete_topic, undo_topic_change and undo_graph_change (with a write-scope token and editor access), and the assistant proposes topic merges, renames, definitions and deletes for the person to approve (propose_topic_change) (MCP, Topics).

Features

  • settings: group the sections and find a setting by name (#390)
  • settings: fold the options most people leave as they are (#392)
  • nav: name the navigation's groups (#394)

0.9.0 October 15, 2026

  • Evidence PDFs hold everything Lens found. After each resource's own file come pages of what Lens knows about it: its details, metadata and custom fields, tags, topics, the summary and analysis models wrote, its sections, speakers, emotion and sounds (overall, by speaker, and marked on each transcript line, such as "(Happy, Laughter)"), entities, keywords, the faces, objects, descriptions and text on screen seen in it, and the places and dates found in it. Notes, highlights and comments people shared go in when picked. Export › Choose what goes in it… (and the button beside a collection's Evidence PDF) picks the parts, and include does the same in the API (Evidence PDFs).
  • Roles with permissions. Viewer, editor and owner are now sets of permissions (read, edit, import, process, curate, share, publish, remove, members, manage), and admins make custom roles from them in Admin → Roles, given per namespace like the built-in ones. Existing access is unchanged. Someone who manages members but isn't an admin gives only roles they hold all of (Access).
  • Invite and remove people. Making an account in Admin → People now emails the sign-in link as an invitation when mail is set up, and the link is still shown to share yourself. Remove for good ends every way someone signs in, takes their roles and their private chats, notes and views, and keeps what they shared under their name; their email can be invited again (Authentication). The assistant gets list_people, invite_person and change_person, which always wait for an admin's approval.
  • Reachable from the network, and the edition, changed in Settings. Settings › Access & embedding has Reachable from other devices on this network, and Settings › Components the edition: Full (LibreOffice and Chromium, to read Office files, web pages and emails) or Lean. Changing either says what will happen and applies with one button: the machine's upgrade agent runs the install again for the version running, and Lens restarts. Before, the install line had to run again with LENS_PUBLIC or LENS_TARGET; without an agent, Settings shows that line. Agents use GET/PUT /api/v1/admin/install and the MCP tools install_settings and set_install_settings (Updates).
  • NSFW classification, with a model on your server. Turn it on in Settings → NSFW for every namespace, or on a namespace's page for that one (off by default). The new nsfw step scores a video's sampled frames, a document's pages or an image with an Apache-2.0 model (fetched on first use, about 105 MB with ONNX Runtime) and flags the resource when a picture scores at least the threshold. Flagged resources are blurred until someone chooses to see them, left out of the Library, or shown with a label; editors can mark one NSFW or not by hand, and Classify existing classifies what was there before. Agents use it through the API and MCP (nsfw_settings, set_nsfw, mark_nsfw) (NSFW classification).
  • Apache Tika. Lens can use an Apache Tika server (--profile tika in the compose files, or one of your own, set in Settings → Documents). Documents nothing here can make into a PDF, such as Word files on a lean server or a Raspberry Pi, are read by Tika as text, and every document's own title, authors, language and dates are read as Dublin Core terms. Off until a Tika server is set (Configuration).
  • New pipelines, workflows, templates and API tokens come with a name. The web app guesses one from what you're making ("Transcribe to report", "Recording workflow", "Prompt template 2", "Read only token"), as it already did for routines, so making one takes no typing; type a name to choose your own. The pencil next to the name renames it later, and PATCH /api/v1/pipelines/{id}, /templates/{id} and /tokens/{id} rename them through the API.
  • Long recordings go to a speech provider in pieces that fit it. A recording longer or larger than a provider takes in one request is cut at pauses into pieces within its limits, and their transcripts are joined back into one, where only OpenAI-compatible servers had ten-minute parts before. Each provider starts with its published limits (10 hours for ElevenLabs and AssemblyAI, 2 GB for Deepgram, 25 MB for OpenAI), editable in Settings → Speech providers (docs).
  • Running the install line again keeps .env readable only by you. Each update rewrote .env with the shell's default permissions, so after an update its secrets could be read by other users of the machine. It is now written for its owner alone, and an install an earlier update left open is closed again the next time the line runs.
  • A microsandbox install whose keys don't open its database comes up when the line runs again. A reinstall made before 0.8.1, after ~/lens was deleted, wrote new keys but kept the earlier install's lens-data volume, and every update after it kept both: its API stopped at every start with SURREAL_USER/SURREAL_PASS don't match. The sandbox now checks that the keys in .env open the database before it starts the API, and when they don't, the install line moves Lens to a volume of its own (lens-data-2, kept in .env as LENS_VOLUME) and starts it there. The earlier volume is left as it is (microsandbox).
  • Lens on a phone. Everything works at phone width now: the namespace switcher is at the top of the menu, Import is in the top bar, Settings opens its sections as a list instead of a row to swipe through, the graph's filters close after a pick, Copy no longer covers webhook addresses, sensors and connections read as cards, and Save and the other form buttons stay in sight above the Ask button. A recording's pipeline steps no longer make its page wider than the screen. On a phone the clicks survey goes from 2 features that couldn't be done to none, and from 15 swipes to 2.

  • Similar content. Every resource has a Similar tab, and selecting text anywhere (a transcript, a page, a title, a graph label) shows the resources most like it, each citing the passages, words, topics and entities they share, with Find more for the full list. Topics, entities and graph nodes list similar content too, and the assistant and the MCP server can ask for it (find_similar). It works without search by meaning, by key words, topics and entities (Processing).

  • Watch a folder in four clicks. Watch a local folder on the Sensors page opens this machine's allowed folders, and each has a Watch button, so it no longer has to be opened first. Adding This machine as a connection skips the test-and-name step unless its test fails (Sensors).

  • MCP servers that run as a program. Most MCP servers in marketplaces run as a program (npx or uvx), not on the network. Admins can now add one as an MCP server extension with its command, args and env, as in Claude's .mcp.json, and a plugin's such servers come along when an admin installs it. They run in the MCP runner, an opt-in container with none of Lens's data or keys (COMPOSE_PROFILES=mcp), start on first use and stop after 15 idle minutes (Assistant).

Features

  • updates: upgrade page with live steps, and roll back when the new version doesn't start (#262)
  • setup: ask only what has no default in the first-run wizard (#376)
  • settings: private networks for web pages and feeds in Settings → Documents (#378)
  • settings: a Hugging Face token in Settings → Components (#379)

Fixes

  • install: install on a computer without Docker or KVM access, and run the line again there (#368)

Dependencies

  • move to lucide-react 1.x (#369)
  • move to Tailwind CSS 4 (#377)

0.8.1 October 12, 2026

  • The installer stops waiting when Lens can't start, and shows why. A microsandbox install that couldn't start Lens waited 30 minutes at "Waiting for the web app..." and then named only a command to run. It now lists the sandbox's own steps as they happen, and stops as soon as a step fails, the sandbox stops or a part of Lens keeps stopping, with the last lines Lens logged; the web installer shows them under the reason. Running the line again stops the running sandbox first and waits for its port (microsandbox).

  • A microsandbox install after the install folder was deleted comes up. The data volume (lens-data) outlives ~/lens, but its database opens only with the keys in that folder's .env. A first install made new keys, mounted the earlier volume anyway, and waited 30 minutes for a web app whose API couldn't open the database. It now leaves that volume as it is and starts on one of its own (lens-data-2, kept in .env as LENS_VOLUME); put the earlier .env back to use the earlier data, or remove it with msb volume rm lens-data (microsandbox).

0.8.0 October 11, 2026

  • A microsandbox's full edition installs LibreOffice and Chromium the first time they're needed. They were installed at every start, so a first start downloaded them before it built Lens, whether or not anything used them. A sandbox now starts without them; the first Office file, email or web page that needs one waits while it's installed, and later starts install it again in the background from the packages kept in the volume. An install that already had them keeps them (microsandbox).
  • A microsandbox install gets past name lookups that fail. The first build in a microsandbox could stop at the backend's packages with dns error ... No address associated with hostname when this machine's resolvers didn't answer inside the sandbox. The sandbox now waits for the names it downloads from, looks them up with public resolvers (1.1.1.1 and 9.9.9.9; LENS_DNS_FALLBACK changes or turns that off) when this machine's don't answer there, and runs each download step up to three times (microsandbox).
  • Running the install line again keeps the lean edition. An install made lean (LENS_TARGET=lean, or the web installer's pick on a computer with less than 4 GB of memory) became full again when the line ran without LENS_TARGET, so the next start installed LibreOffice and Chromium. It now keeps the edition the install has, as it does the port and the network setting (Get started).

  • A Mac app. Lens.app (Lens-macOS-arm64.dmg, Apple silicon) installs Lens in the web installer shown in its window, with microsandbox inside it so no Docker is needed, then lives in the menu bar: open, start, stop and update Lens, and show its logs. Lens itself opens in the browser, where passkeys work (Get started). It updates itself from each release, checking the update's signature, once the repository has an update key (desktop/README.md).

  • Slack, built in. Settings › Slack creates a Slack app from Lens's manifest in one click; paste its two tokens and the assistant answers direct messages, what's said to it in channels and /lens …, over Socket Mode: no Matterbridge, no public address. Voice clips are heard, files are held for the assistant to import, and everything Matterbridge rooms do works there (Chat rooms).

  • The file tree over NFS. Settings → NFS share serves the archive's folders and files (namespaces, collections, topics and entities as folders, assets as files) read-only over NFS, to the networks you allow, showing what one chosen account sees. Off until turned on; published on port 12049 of the host (File shares).

  • The graph as files over SMB. Settings → SMB share serves the file tree read-only to Finder, Linux and (on port 445) Windows: each person signs in with a file access key and sees what the web app shows them, from the networks an admin allows. Off by default; the SMB server (impacket) is fetched when it's turned on (SMB share).

  • A spending limit from the start. The setup wizard has a Budget step, and the web installer asks for one with a cloud provider: a scale for a hard limit on everything Lens spends on AI, with a soft limit at 80% that warns. At the hard limit runs wait (or are skipped) and paid model calls stop until it goes up; models on your own computer keep working. The model's price comes with it, so the spending is counted. It's the new all budget, changeable in Settings › Budgets (Budgets). The web installer now asks only the essentials: the folder, port and network under Advanced, the optional extras under More options on the review.

  • Cloud model presets. Settings → LLM provider and the setup wizard offer OpenAI, Anthropic, Google Gemini, OpenRouter, Groq, Mistral and DeepSeek next to the model servers found nearby: one click fills in the address and a model and puts the cursor in the API key field, with a link to where the provider hands out keys, so switching to a cloud model is a click and a paste. Agents get the same list from GET /api/v1/setup/llm/providers and the assistant's find_model_servers tool (Cloud model providers).

  • The graph as files. Graph nodes are folders, nested along its links: collections inside collections, topics inside their broader topics, entities by type. Each folder holds its assets' files, with a .json sidecar of metadata and a .txt of the text; everyone sees what the web app shows them. Browse and read it through the API (/api/v1/tree) and the MCP tools list_files and read_file (The graph as files). An NFS share is in progress.

  • HTTPS with automatic certificates. Lens can serve itself at https:// your domain with a certificate it gets and renews on its own from Let's Encrypt or ZeroSSL (or any ACME authority), proving the domain over HTTP, TLS-ALPN or DNS, with Cloudflare, Route 53 and the other caddy-dns providers, so wildcards and machines the internet can't reach work too. Set it up in Settings › HTTPS, whose Open the HTTPS ports has the machine open 443 and 80 and restart Lens (or run the install line with LENS_HTTPS=1). It can answer only for its own domains (strict SNI) and require client certificates (mutual TLS). Off by default (HTTPS).

  • Budget meter and Costs page. The top bar shows what the namespace picked spent against its budget, used and left, highlighted when near or over, and follows the namespace switcher; it opens the details for a day, week or month. Costs in the left menu shows spend by day, model and resource, and sets the namespace's budget (Budgets).

  • The graph over S3 and WebDAV. Turn on Settings, File shares, and any S3 client (rclone, Cyberduck, the AWS CLI) or WebDAV client (Finder, Windows Explorer, file managers) lists and reads the graph as folders and files, read-only, with access keys each person makes on their API tokens page (S3, WebDAV). Docker publishes S3 on port 9555 (LENS_S3_PORT) and WebDAV on 9556 (LENS_WEBDAV_PORT), where the web app is.

  • Ask Lens from your browser. The web clipper extension has the Lens assistant in a side panel: Ask Lens in its popup or on the right-click menu. It signs in with one click through Lens's own sign-in page (OAuth, nothing to copy), and the conversations are the same ones Lens's chat shows, with cited answers, the steps taken and approvals. While the panel is open, the assistant can use that browser: open, read, click, type and download files into the conversation, only on sites you allow, asking before anything that sends, deletes, buys or posts, never typing passwords or card numbers, and showing each action as it happens. Once it has read a page, opening an address or typing text you didn't write asks you first too (The assistant in your browser).

  • Telegram, built in. Paste a bot token from @BotFather in Settings › Telegram and the assistant answers there: no Matterbridge, no public address. It talks only to people you let in (one click on the settings page), hears voice notes, takes files for the assistant to import, and does everything it does in Matterbridge rooms (Chat rooms). Chat apps now share one channel layer, so the next one is an adapter.

  • The assistant by email. Give it an address of its own in Settings › Assistant email (the address and an app password; the servers are guessed) and the people you let in can email or forward things to it: it answers by email in the same thread, lists the excerpts it cites with links, and imports what's attached, a forwarded email whole. It answers only mail their provider vouched for (DMARC, DKIM or SPF), never automatic mail, and holds a stranger's mail until you let them in. lens+pods@… or a routing rule sends mail to a namespace's assistant (The assistant by email).

Features

  • assistant: mark third-party text as data in the model's prompts (#302)
  • backups: restore, download, any schedule and copies to an rclone connection from the web app (#263)
  • assistant: let the assistant use a browser connected through the Lens side panel (#311)
  • clipper: build the web clipper for Firefox (#282)
  • assistant: ask before sending data out once an answer read third-party text (#309)
  • monitoring: Prometheus, node-exporter and Grafana as an opt-in compose profile (#297)
  • install: install with the defaults in one click; the API token form opens itself (#331)
  • assistant: flag third-party text that reads like instructions to an AI (#329)
  • ui: new pipelines and routines are named for you and Enter creates them; Enter in a note's title goes to its first line (#336)
  • bridge: break up links a chat-room answer built from what it read (#338)
  • mcp: hand agents what the archive says as data (#343)
  • audit: log what Lens caught of prompt injection (#344)
  • assistant: an optional local classifier for prompt injection (#347)
  • turn Prometheus and Grafana on from the web app (#337)
  • ui: copy the MCP address from the account menu after setup (#340)

Fixes

  • release: keep the API schema's version in step with each release (#310)
  • ui: fewer clicks in setup, Chat, Search and Notes (#315)
  • jobs: skip transcribe and diarize for imported transcripts (#345)
  • ui: open the graph on the namespace that has one (#339)
  • desktop: check the update's signature against the repository's key at build time (#354)

Dependencies

  • bump the python-minor group (#253)
  • bump the javascript-minor group (#316)

  • Logs in the web app, and to a syslog server. The API and every worker now write structured log lines with a level set in Settings → Logs (and per logger, like httpx warning), to the console as text or JSON and to a file per process under data_dir/logs. The same page shows the latest lines from all of them, with search and filters. Turned on there, lines go to a syslog server too (RFC 5424 over UDP, TCP or TLS), from a background queue so a slow server never holds Lens up; off by default. Keys, tokens and passwords are replaced with *** before any line is written or sent (Logging).

0.7.0 October 10, 2026

  • Prometheus metrics. Lens can serve /metrics for Prometheus and Grafana: jobs by status and pipeline, imports, model calls, tokens, cost and failures by model, budgets, workers and disk. Off by default; turned on in Settings → Telemetry (or LENS_PROMETHEUS=on), it answers only a scraper with the scrape token, and follows LENS_BIND. A sample Grafana dashboard comes with it (Prometheus).

  • Install on Windows, or by double-clicking. install.ps1 installs Lens on Windows: it gets Python 3 if needed and opens the web installer, which installs Docker Desktop (and WSL) and runs Lens in it. Each release now carries launchers that need no terminal at all: Install Lens.app for macOS, Install Lens.cmd for Windows and Install Lens.desktop for Linux, each opening the web installer (Get started).

  • Install in the browser. On a first install at a terminal, the one-line install opens a web installer: it shows what the computer has and what Lens will install, asks how Lens runs (Docker or microsandbox, full or lean, folder, port, network), the AI model (finding Ollama, LM Studio and other servers on the computer, or a provider and its key, with a test), and optional extras (search by meaning, the decision model, cloud transcription, email, remote access, anytopdf, telemetry), takes the administrator password when installing needs it, then installs with live progress and opens Lens's setup page. Its answers become ordinary settings in Lens, editable there (Get started). LENS_WIZARD=0 keeps the install in the terminal.

  • Assets, not recordings. The web app, the assistant, the API descriptions and the docs now call an item of any kind an asset (or a document, when it is one), and keep "recording" for audio and video. API paths, MCP tool names and stored data are unchanged.
  • People, not speakers. Everyone Lens recognises by voice or face is a person, on the People page, in the graph and for the assistant; names found in text are named people. An item's text is its text, not its transcript, unless it came from speech, and search says where words are found, not where they were said. The admin's account list is now Members. "Speaker" stays for voices in audio (who spoke when).

Features

  • updates: release check, backups and one-click upgrade through a machine agent (#260)
  • documents: scroll through every page of a document in one view (#267)
  • updates: tell admins and notification targets when a new release is out (#270)
  • chat: /clear and /compact commands, and early compaction near the context limit (#269)
  • voice: read aloud with Gemini TTS or macOS say, and pick the voice in Settings (#268)
  • clipper: Lens web clipper browser extension, Chrome first (#277)
  • import: capture one URL, several or a list from the API and the assistant (#272)
  • extensions: install skills, plugins and MCP servers from Anthropic-format marketplaces (#276)
  • clipper: publish browser extension releases for auto-updates (#294)
  • voice: read aloud with StyleTTS2 on this machine, as an opt-in service (#291)
  • clipper: Edge build, Edge Add-ons package and Edge smoke test (#285)
  • search: find content similar to a text, a resource, a topic or an entity (#296)
  • clipper: Safari build of the web clipper (#283)

Fixes

  • jobs: read documents and images instead of transcribing them (#265)
  • sources: let Google Drive sign in with a service account key file (#266)
  • migrations: read documents already waiting to be transcribed (#278)
  • sources: keep working when Google Drive rate limits (#288)
  • sensors: keep a retained MQTT message before acknowledging it (#299)

Dependencies

  • bump @tanstack/react-query (#249)
  • bump dotenv from 16.4.5 to 18.0.5 in /nextjs-frontend (#252)
  • close the frontend's open security alerts (#300)

0.6.0 October 10, 2026

Features

  • install: list the prerequisites and ask before installing them (#258)

Fixes

  • install: pull the microsandbox base image from mirrors that need no sign-in (#259)

0.5.0 October 10, 2026

  • Podcasts from what you pick. Lens makes a two-host learning episode about resources and passages: one host explains, the other asks what a learner would ask, every claim is cited to the passage it comes from and checked against it before it's kept, and the script is read aloud in two voices by your text-to-speech provider (a local OpenAI-compatible server works). Episodes are recordings in a podcasts namespace, so they're searchable and on the graph. Start one from the Library's selection bar or a resource's menu, with POST /api/v1/podcasts, or with the MCP tool create_podcast; each episode's page plays it beside its cited script (Podcasts).

Features

  • install: run Lens in a microsandbox microVM as an alternative to Docker (#255)

Fixes

  • store: say which setting to fix when SurrealDB refuses the password (#254)

0.4.2 October 9, 2026

Dependencies

  • bump jest to 30 and keep BlockSuite on 0.19 (#241)

0.4.1 October 9, 2026

Fixes

  • release: cut release notes over GitHub's 125,000 character limit (#236)

Dependencies

  • bump backend and frontend dependencies, TypeScript 6 included (#239)

0.4.0 October 9, 2026

  • Import videos and podcasts by their address. Turn on Settings › Documents › Import videos and podcasts by their address (off by default), then Import › Web page › It's a video or a podcast. Lens downloads its media with yt-dlp, fetched on first use, through the same public-addresses-only proxy as web pages, and transcribes it (Videos and podcasts).
  • Zip and tar archives. Upload a .zip, .tar or .tgz and Lens lists what's in it, keeps its files, and reads the ones it can as resources of their own (a WhatsApp export with media becomes the chat and its photos). Encrypted entries, links and nested archives aren't opened, and unpacking stops at 100 files or 512 MB (Archives).
  • Camera RAW and HEIC photos. DNG, CR2, CR3, NEF, ARW, ORF, RAF, RW2, HEIC and HEIF can be uploaded and scanned when anytopdf is here or a conversion node is set: anytopdf reads them, and RAW photos get the new Camera RAW photo content type. Without anytopdf, Lens says what reading them needs (anytopdf).
  • Capture many web pages at once. Import › Web page › Many pages at once takes pasted links, a browser's bookmark export or Chrome's Bookmarks file. Each link is captured like a single page, and its bookmark folders become its tags. Links already captured in the namespace are left as they are (Web pages).
  • JSON and JSON Lines as records. A JSON file that isn't a transcript (orders, contacts, sensor logs, an API dump) is read one record at a time, every field searchable, each line pointing back at its record; it gets the new Records content type. JSON files can now be uploaded with the other files (Records).
  • Export an evidence PDF. A resource's Export menu, and a collection's page, download it as one PDF made by anytopdf: each file's pages (audio and video with their transcript), searchable, then a provenance page with every file's SHA-256, so it can be handed on and checked (Evidence PDFs).
  • anytopdf can find faces. Settings › Video, OCR, faces and objects › Face engine › anytopdf: its plugins find the faces in frames and pages and describe them with the same SFace model, so a server needs no OpenCV, and the people a namespace already knows still match (Video). Who someone is stays in Lens.
  • Search photos by what they show. Turn on Settings › Search › Search photos by what they show (off by default) and Lens downloads anytopdf's CLIP model (about 600 MB). Search then also shows the photos most like what you typed, such as "a bus on a city street", whatever words are on them (anytopdf).
  • anytopdf can find objects. Settings › Video › Object detector › anytopdf runs Lens's YOLOX model with anytopdf's objects plugin (sandboxed, no network), so a lean server or a Raspberry Pi finds objects without ONNX Runtime. Lens fetches anytopdf and the model on first use. YOLOX on ONNX Runtime stays the default (Objects).
  • Places and dates from anytopdf. Turn on Settings › Analysis › Find places and dates with anytopdf (off by default) and analysis also asks anytopdf, offline, for the places a text names, the dates it gives (with their ISO value) and where a photo was taken (from its GPS fix, with exiftool installed). They become places and dates in the graph, and the resource's Details tab lists them for the namespace's members (anytopdf).
  • WhatsApp, Telegram, Slack and iMessage chats. Import an exported chat (or upload a WhatsApp .txt) and Lens reads it message by message, with who sent each one as its speaker and when it was sent, so the assistant can answer from your real conversations. It gets the new Chat export content type (Chat exports).
  • anytopdf can make Lens's PDFs. Settings › Documents › Make PDFs with anytopdf. On auto (the default) nothing changes unless anytopdf is here: then it reads the emails, web pages and text a server without Chromium couldn't. Choose "For every document and image" and Lens downloads anytopdf 0.4.0 (checked against its release checksum); it then also reads images, flattening a photographed page before OCR. Or set a conversion node, another machine running anytopdf queue serve, and a small server reads Word, PowerPoint and spreadsheet files too (anytopdf).
  • Where anytopdf made a document's PDF, Lens uses the words it read. anytopdf 0.4.0 lists every word it found on a picture page with where it is, so a photographed or scanned page's text comes from anytopdf's OCR, block by block with its place on the page, without Lens reading the page again. Pages without those words are read as before.
  • A conversion node gets the token and nothing more. Lens refuses a node that redirects (so its token never goes on to another server), caps what the node answers and refuses a PDF that isn't one. Settings › Documents › Check it says whether the node answers and takes the token (anytopdf).
  • Web pages and emails keep their look when anytopdf makes the PDF. Where Chromium and pdftoppm are installed, anytopdf draws the page as page images, offline, with its text as the search layer. Without them it reads the text, as before.
  • Connect Slack, WhatsApp, Telegram, Discord and Matrix from the web app. Start the optional matterbridge service, then turn on Settings › Chat rooms › Run Matterbridge here and add a bot token and rooms. To link WhatsApp, scan the QR code the page shows. Lens writes Matterbridge's config, a gateway per room, and restarts it when the settings change.
  • Visitors can no longer choose the address IP groups and sign-in throttles see. The web app drops an X-Forwarded-For a browser sends and tells the API the address it was reached from. Behind a reverse proxy, set TRUST_PROXY_HEADERS=true on the web app, or every visitor counts as the proxy's address (Trusted proxies). The Cloudflare tunnel keeps working as before.
  • The sensor hub's ports follow the web app's. The production compose file publishes MQTT (1883) and syslog (5514) where the web app is: on this machine only unless LENS_BIND=0.0.0.0 (which install.sh sets on a server). If devices on your network send to a Lens whose web app is local only, set LENS_SENSOR_BIND=0.0.0.0 (Sensors).
  • Approve the assistant's reversible changes from the chat room. When Lens proposes merging entities or a new namespace in Slack, WhatsApp or another bridged room, the people listed in Settings › Chat rooms can reply "yes" or "no" there instead of opening the web app. Settings, imports, runs and extensions still wait for the web app.
  • Chat rooms route each question to its namespace. In a room no namespace is given to, Lens picks the namespace each question is about (or the one whose assistant it names), says where it looked, and names the likeliest when it isn't sure. "use pods" keeps the conversation in one namespace until "use everything".
  • Long conversations are summarised. The assistant reads the latest messages word for word and a running summary of the older ones, so long chats and chat-room conversations keep what was said instead of forgetting all but the last six messages. Settings › AI assistant › Summarise long conversations turns it off.
  • Encryption at rest has its own page in Settings. Settings › Encryption turns encrypting files on disk on or off, and the files already kept follow in the background, with how far it has got on the page. Turning it off leaves vaults' files encrypted (also for lens encrypt --off). It also sets how long working copies and unlocked vaults last.
  • A moved recording's files move to the new namespace's key. Moving a recording re-encrypts its files with the key of the namespace it goes to, so they open only with that namespace's key, and a vault it leaves no longer holds them. Moving out of or into a locked vault waits until it's unlocked (423).
  • The copies Lens keeps of files on storage sources are encrypted too. With encryption at rest on (or in a vault), the cached copy of a recording that lives on S3, Drive, a mailbox or another connection is stored with its namespace's key, and still plays, seeks and is processed as before. lens encrypt converts the ones already there.
  • Frames, page images and face crops are encrypted too. With encryption at rest on (or in a vault), the stills Lens draws from videos, documents and images are stored encrypted and still show as before; visitors still get faces pixelated where the namespace asks. lens encrypt converts the ones already there.
  • Word search no longer weighs down the database. Searching words in transcripts, text on screen, files, objects and descriptions now uses a small SQLite index next to the database instead of SurrealDB's own full-text index. On 10,000 transcript lines the database used 64 MB of memory instead of 380 MB, and writing them took half the time. Nothing changes in how search, the assistant or IIIF search behave. Existing installs build the new index on their first start and give back the old one's memory. Settings › Search › Word search switches back to SurrealDB's index, or to an OpenSearch cluster on another machine for a big archive (then Reindex now); the opensearch compose profile runs one next to Lens.

  • A much smaller web app image. The production image runs Next.js's standalone server with only the packages it uses: 465 MB instead of 2.56 GB. Cloudron and pnpm build keep next start.

  • Renditions, reports and exports are encrypted too. With encryption at rest on (or in a vault), the PDF made of a Word file or an email, the HTML reports and the exports Lens keeps are stored encrypted, and download and open as before. lens encrypt converts the ones already there. An export sent to a storage connection still arrives plain.

  • Fewer steps to an answer. Asking on Home sends the question when you press Enter, instead of opening an empty chat on the first keystroke and waiting for a second Enter. With only one namespace, it is picked for you, so pages that need a namespace stop asking. Once a model is set, the setup wizard offers Finish with defaults for the remaining steps. The Import button shows on tablet-sized screens too, and an empty graph offers Import instead of a list of jobs when nothing is waiting.

  • Engines and models are fetched on first use. A fresh install no longer downloads PyTorch, SenseVoice, voice IDs and the face and object models before anything is imported (2.7 GB, and 2.6 GB of memory held by an idle worker). Each one is fetched the first time a recording needs it; Settings › Components shows it as On first use until then. An empty stack now idles at about 270 MB of memory, small enough for a Raspberry Pi with cloud models. Fetch everything now (components.ahead) brings back fetching ahead.

  • Smaller images. The API and worker image no longer carries uv's package cache, and the web app's image no longer carries Next.js's build cache: about 0.9 GB and 1.2 GB less on disk, and less to pull and build on small machines.

  • No dead ends after setup. Finishing or skipping the setup wizard without a namespace now creates "archive", so import, watched folders, topics and the graph have somewhere to go. The chat's "set up a model" links open the model settings (they opened Speakers), and the bell opens the overview of what needs you in assistant mode too.

  • The graph canvas as of a version. A clock menu on the canvas toolbar shows the entity graph as of a named version or as it was before one of the latest changes. Exploring, paths and questions use that version too.

  • Graph changes in activity history. An entity's, a namespace's and a recording's activity history now list the graph changes that touched them, with who, how and why, and the version to find in Routines › History.

  • The entity graph keeps its history. Every change to entities, their other names, links across namespaces and "not the same" pairs is recorded as a numbered version: what changed, who (a person, a routine, the assistant, analysis), through what (the web app, a token, an app, MCP) and why. GET /api/v1/graph/history lists versions (one entity's with entity), /graph/as-of/{version} shows the graph as it was, /graph/diff what changed between two versions, and /graph/tags names versions to come back to; the graph API and Cypher take as_of to read the graph at a version. POST /graph/rollback takes the graph back to a version, after a preview, undoing merges with their mentions; a rollback can be rolled back too. Checkpoints let the graph be replayed from its history, and lens history verify (or /graph/verify) checks the replay matches today's graph. See docs/graph-history.md. In the web app, Routines › History shows every change and what it did, and names or rolls back to a version; an entity's History button shows its own.
  • Apps and AI assistants in the setup wizard. A new optional step, Apps and AI, asks whether apps and AI assistants (Claude, ChatGPT, Cursor and other MCP clients) may sign people in with their Lens account, how long their tokens last, and shows the MCP server's address to add to an assistant. Skipping it keeps OAuth on, as before. Admins can also turn it off in Settings → API keys (tokens.oauth_enabled): discovery and registration go away and apps' tokens stop working until it is back on. API: PUT /api/v1/setup/oauth.
  • Budgets for routines, pipelines, workflows and namespaces. An admin caps what a resource may cost, in USD, tokens or both, per run, day, week or month. Before a routine runs or a job starts, Lens estimates the run from past ones; one that would go over is held until someone picks run or skip (or is skipped, or the decision model weighs it when asked to). Budgets near or over their cap are flagged once per period. Off unless set. See docs/budgets.md.
  • Local models cost nothing unless you price them. A model's price is by tokens (USD per million), by time (USD per hour, for a model on your own machine) or off, the default for a model without one.

  • Every resource has an activity history, with what it cost. Lens now keeps a ledger of every request that changes something, every call it makes out (models, embeddings, the decision model, text to speech, webhooks, web tools) and every job and routine run, each counted for the resources it touched: a model call in a job a routine queued counts for the job, its recording, namespace, pipeline, workflow and the routine. Tokens and estimated cost come from the prices in Settings. GET /api/v1/activity?resource=routine:3 gives a resource's history (with its audit log entries), /activity/totals what it cost this day, week or month, /activity/top what cost most. On by default; activity.keep_days (365) keeps it in bounds. See docs/activity.md.

  • Safer upgrades of existing installs. Data upgrades are now named steps that run once per database, under a lock so only one process runs them while the others wait, with a record of when each ran and why one failed. lens migrations lists them. A database with data is backed up into <data_dir>/backups before it is upgraded (the newest three are kept), and lens backup takes one on demand. See docs/database.md, Upgrades.
  • Routine choices on your own Mac with Laya. decisions.engine: laya takes routine choices with a Laya typed decision model on MLX (English, multilingual or typed-decisions), fetched by Lens itself, free and offline. Lens in Docker on a Mac asks lens decide-server on the Mac. Elsewhere it says Laya isn't available and the LLM decides. Jev stays the default. Settings → AI assistant → Routine choices has a Test button. See docs/assistant.md.
  • Topics, apart from entities. Each namespace has a controlled vocabulary of topics (SKOS): a label, other labels, a definition, and broader, narrower and related topics. Recordings are about topics, said by a person or brought over when a topic-like entity (type TERM) becomes a topic; the entity is hidden until the topic is deleted. Editors create, edit, merge and delete topics through /api/v1/topics. Topics are in the graph (Topic, with ABOUT, NARROWER and RELATED; TERM entities are now labelled Term there) and in RDF (/id/topic/<id>). See docs/topics.md.

  • Topics in notes and for agents. # in a note links a topic, found by any of its labels, and every topic has a page of its own. The assistant can find topics, read one with its recordings and suggest a topic for recordings; the MCP server has list_topics, get_topic and suggest_topic. Their suggestions wait for someone to accept.

  • Topic suggestions. Analysis suggests the vocabulary's topics for recordings whose summary or transcript says one of their labels, for people to accept or dismiss (a dismissed one isn't suggested again). The Topics page lists what summaries say recordings are about that no topic covers yet, to add as a topic or skip. No model is called.

  • A Topics page. Topics in the navigation shows a namespace's vocabulary as a tree of broader and narrower topics, with search, and a drawer to edit, merge or delete a topic and see the recordings about it. A recording's Entities tab lists its topics for editors to add, remove or accept; a Term entity can be made a topic from its drawer; topics show on the graph canvas as green tags. Entities of type TERM are now shown as Terms.

  • Query the graph in Cypher, and walk it. The archive is now a property graph of namespaces, collections, recordings, speakers and entities. POST /api/v1/graph/query runs read-only Cypher (the language of Neo4j and ISO GQL) over the namespaces you can read; /graph/related gives a node's parents, children, ancestors, descendants or neighbours, /graph/paths the paths between two nodes, and /graph/schema what a query can ask about. Agents with a write-scope token and editor access ask for merges and links with POST /api/v1/graph/changes; they wait in Proposed changes unless asked to apply. See docs/graph.md.

  • Explore the graph on a canvas. Drag nodes, pan and zoom with a mouse or by touch (pinch, long-press), switch between force, BFS tree, DFS tree and radial layouts in one click, and reset. Right-click a node (or long-press it) for its parents, children, ancestors, descendants, neighbours and paths, or to route through it; what is found joins the canvas. Recordings, collections and namespaces show on the canvas alongside speakers and entities.

  • Ask the graph. The bar under the canvas takes a question in plain words or Cypher. A language model writes the Cypher for a question (POST /api/v1/graph/ask); the answer shows the query, to edit and run again, and lights up what it found.
  • The assistant and MCP clients can query the graph. Chat's assistant has graph schema, query, related and paths tools over the chat's namespaces; the MCP server has the same, read-only, and propose_graph_change for write-scope tokens.

  • Lock a namespace to your passkeys. An owner turns a namespace into a vault (Admin › Namespaces › Vault): its files open only after one of its passkeys unlocks it, for an hour by default (encryption.vault_minutes), and its queued work waits while it's locked. The passkey's WebAuthn PRF secret makes the key; the server keeps nothing that opens it, and there is no recovery code, so add a second passkey. Owners add and remove passkeys while it's open (never the last one) and can make it ordinary again. See docs/encryption.md#vaults.

  • Hardening. Sign-in throttles count each visitor behind the web app (Docker) instead of one bucket for everyone, so one person's wrong tries can't lock others out and nobody gets unlimited tries. The web app's pages send nosniff, referrer, permissions and frame policies, and HSTS when reached through Cloudflare. The containers run with no-new-privileges, and the development API port listens on this machine only. See docs/deployment.md.
  • Reach Lens from anywhere through a Cloudflare Tunnel. Settings › Remote access turns on a tunnel Lens runs itself, with nothing to open on the router: a quick random trycloudflare.com address, a hostname on your own Cloudflare domain (Lens makes the tunnel and DNS record with an API token), or a tunnel made in the Cloudflare dashboard. The page shows the address, whether it's connected and cloudflared's last lines; Lens fetches cloudflared when it isn't installed. Passkeys and email links use the tunnel's https:// address. See docs/remote-access.md.
  • Signing out keeps you at the address you're on. Opened at another address than the one Lens was installed with (its LAN name, an https:// address, the tunnel), signing out sent you to http://localhost:3000. Sign-in now follows the browser's address; the Docker Compose files no longer pin AUTH_URL.
  • Sign in with Google, GitHub, Microsoft or your own OpenID Connect provider. Admins add them in Settings › Sign-in (client id, secret, and the redirect URI to register, which the dialog shows), and the sign-in page gets a "Continue with ..." button for each. People are matched to their Lens account by an email the provider confirms, or connect an account in Profile and sign-in › Connected accounts. Sign-up for people without an account is optional, and can be limited to some email domains. See docs/authentication.md.
  • An old passkey no longer blocks signing in. After Lens is set up again at the same address, the browser still offered the passkey from before (clearing the site's data doesn't remove passkeys from the password manager), and signing in with it failed. Lens now says the passkey is from before, and tells Chrome and Safari to stop offering it, so the next try shows the new one.
  • A watched folder says which pipeline it runs. "Namespace pipeline" names the namespace's default pipeline (or the standard steps) right there, instead of pointing you to the Pipelines page to find out.
  • Sensors in the web app. Admins get Sensors in place of Sources: the hub's state, new devices with a suggested handling to apply or ignore, each sensor's streams with charts of hourly averages and its latest readings, its kinds of log line to label or drop, and what it keeps and for how long. Webhooks (token shown once), bridges, hub logins and Settings → Sensors are all there; storage, email and calendars stay a tab away.
  • An email connection fills in its server. Type the address first and the IMAP server follows from it (Gmail, Outlook, iCloud, Yahoo and other big providers by name, otherwise imap.) until you change it by hand.
  • Fewer confirms, fewer dead ends. Import sends the files that are ready and keeps the ones that still need a field mapped in the list, instead of blocking everything. Disabling an account and deleting your own saved view happen at once with Undo instead of a confirm. Adding a member to a namespace suggests the people who aren't in it yet. Add file on a recording opens the file picker first (or take a file dropped on the tab) and guesses its role and language from the name (talk.en.vtt is English).
  • Sign in with a passkey, no passwords. People sign in with their fingerprint, face or device PIN (or a phone nearby). The first admin makes a passkey on the setup page; everyone else gets a one-time sign-in link from People (or lens users link), and "Lost your passkey?" emails one. Profile and sign-in lists your passkeys, adds more and removes old ones. Fresh installs have no passwords at all; installs that already use passwords keep them until an admin turns them off in Settings › Sign-in (which needs an admin with a passkey). Passkeys need an https:// address or localhost; on a plain http:// address setup takes the code alone and a sign-in link signs in by itself, so there is still no password.
  • Adding a source takes fewer steps. Picking a type moves straight to its details, the tested connection is named on the same screen (its suggested name kept with Done), and the folder browser opens next so you can pick what to watch. "Connect a source" links elsewhere in the app open Add connection directly.
  • Settings save in one click. Save (or Ctrl/Cmd+S) applies a section's changes at once; only a new public base URL, which changes every IIIF identifier, is still reviewed and typed out first.
  • Less typing and clicking. Every dialog opens on its first field, and Enter there runs its main action. A web page's address needs no https://, namespace names are cleaned up as you type ("Customer Calls" becomes customer-calls), a routine left unnamed is named from its schedule and actions, and a new account gets its name from its email and a role in a namespace in the same step. Deleting a connection with no watched folders no longer asks you to type its name. The entities page offers Clear filters and Add an entity when it's empty, and a setup chat has no empty sources column.
  • Global chats pick their namespace. The first question in a conversation over everything (the assistant home) is matched to the namespace it's about by the decision model (or the language model without one), using what each namespace holds and where the question's excerpts are. A sure match narrows the conversation (a scoped event and a "Looked in …" step; the app says so, with Use everything to undo). When it isn't sure, the likeliest namespaces are offered to tap (a suggested event); when none fits, admins are also offered new ones named for the question, created only when picked. In voice mode, saying one picks it. Nothing changes until something is picked. A scope you chose, and later questions, are left alone.
  • Files are encrypted at rest. Each namespace has its own AES-256-GCM key, and uploads, attachments, captured web pages and IIIF imports are stored encrypted with it. Audio and video still stream with seeking. New archives encrypt from the start. For an archive that already has files, lens encrypt turns it on and converts them. See docs/encryption.md.
  • Sensors understand their logs. Log lines are grouped into patterns (query[A] <name> from <ip>), each with a count, an example, a label (routine, notable, alert) and an action (drop: counted, not kept). With triage on, the decision model labels new patterns, one question per pattern, not per line. Brokers you already run (Mosquitto, Home Assistant) can be bridged in: Lens subscribes to the topics you name. With digest on, each day of a sensor becomes a document in its namespace. All three run in the Tidy sensor data routine.
  • The assistant answers in chat rooms. Through Matterbridge, Lens answers what's said to it in Slack, Discord, Telegram, Matrix and other rooms ("Lens, …" or @Lens), as one Lens account, reading only what that account can and asking for approval in the web app before changing anything. Each person in each room is a conversation listed in Chat. Set up in Settings → Chat rooms with Check the connection; one server process reads the rooms at a time. See docs/chat-rooms.md.
  • Email is set up in the app. Settings → Email holds the SMTP server, its password (kept secret) and the From address, with Send a test email; MAIL_* in .env still work and show locked. Links in emails use notifications.app_url when it's set.
  • Sensors. Everything that feeds Lens is a sensor, in one list (GET /sensors): the storage, email and calendar sources as they are, and new stream sensors. Lens runs an MQTT hub (an ordinary local broker on 1883, with hub logins), listens for syslog from local networks (UDP and TCP 5514) and takes webhooks. Devices and hosts become sensors the first time they report, marked new, with their streams and kinds worked out from what they send. Readings are stored as they come (numbers also as hourly summaries), with per-sensor handling (all, changes, summaries only, none), retention (raw, important log lines, summaries) and a rate cap; a suggested handling is applied only when chosen. Retention runs as a routine action (Tidy sensor data, hourly). Off until turned on in Settings → Sensors; no model is called. See docs/sensors.md.
  • Voice stays on the server. The mic records in the browser and this server turns it into text with its own transcription engine (POST /voice/transcribe), showing what's been heard while you talk and ending at a pause; it works in any browser that can record. Spoken answers can come from a speech model (voice.tts_model, any OpenAI-compatible /audio/speech), else the browser reads them. Settings → AI assistant → Voice.

  • Lens fetches what it needs. Each worker checks what the settings ask for (SenseVoice or a Whisper model, voice IDs, face and object models, the chat and embedding models on Ollama) and fetches what's missing into the data folder, sized to its machine (PyTorch's CPU build without a GPU). Steps wait for their engine instead of falling back. Models now outlast container rebuilds. Settings → Components shows each worker's machine and where everything is (docs/components.md).

  • Assistant mode. Home opens on a page with one field and a big mic once the archive has something in it (an empty archive still opens on the overview; the Assistant / Overview switch at the top right remembers your pick). Touching or typing in the field turns it into a chat over all your namespaces; the mic starts a voice conversation straight away: it listens, sends what you said, reads the answer aloud and listens again, until you tap the mic or say nothing twice. Recent conversations are a tap below the field. Chat's composer has the same mic. Voice uses the browser's speech recognition and synthesis for now, behind one hook (lib/voice.ts).
  • The assistant decides routine choices. Files sent in a conversation go into the namespace that fits without asking, chosen by a decision model (Jev, with a key in Settings → AI assistant or TYPESAFE_API_KEY) or the LLM. Below decisions.act_above confidence it asks, best guess first. The admin tools are now listed in Settings → AI assistant, where each can be turned off.

  • Set up Lens by talking to it. Once a model is connected, Finish with the assistant in the setup wizard opens a setup conversation: the assistant checks what's missing and sets it up (the model provider, namespaces, search by meaning) through new admin tools (server_status, find_model_servers, read_settings, change_settings, create_namespace), and says what it changed. In other conversations those changes are approval cards; telemetry always asks.

  • Files in chat. Attach files to a message (the clip, or drop or paste them): they upload while you type, held out of the archive (hold on POST /uploads), and the assistant imports them into the namespace that fits.
  • No waiting to type. A message sent while an answer is being written waits its turn and goes next.
  • Chat shows answers that cite nothing. An answer without citations (a greeting, setting the server up) was replaced by "Nothing in this scope answers that"; that card now shows only when the model says the archive doesn't cover the question.
  • Setup finds your model server. The setup wizard looks for Ollama, LM Studio, llama.cpp, vLLM and LocalAI on their usual ports (this machine, the Docker host and an ollama service; GET /setup/llm/detect) and fills in the address and a chat model, with the server's models to pick from, so connecting one is a single click. The compose files map host.docker.internal on Linux too. When the namespace is already set, the wizard starts at the model provider instead of a step with nothing to choose.
  • Install in one line. curl -fsSL https://raw.githubusercontent.com/adeelahmad/lense/main/install.sh | sh installs Docker if it's missing, gets Lens, writes the secrets once, builds and starts the stack and opens the setup page with the setup code filled in. On a server without a desktop, Lens is reachable from the network (LENS_BIND/LENS_PORT in docker-compose.prod.yml, 127.0.0.1:3000 by default as before). Running it again updates Lens and keeps the data.
  • The whole graph fits on the canvas. Workflow and pipeline canvases showed wide graphs cut off at both edges, or an empty canvas, because the view couldn't zoom out past half size and was fitted only once, before the nodes were measured. The canvas now zooms out as far as it needs to, fits again when the nodes are measured, when nodes are added or removed and when the canvas changes size (until you pan or zoom yourself), and the minimap shows the nodes.
  • Chat answers from the archive even when the model skips its tools. Some models answer straight away instead of searching, so chat said the archive didn't cover things it did. When the model answers without looking anything up and the archive has matching passages, the answer now comes from those passages, with citations, as it does for a model that can't use tools.
  • Docker Compose builds the full image by default. docker compose up and make dev built the lean image, so capturing web pages and converting Office files, text and emails failed with "needs Chromium or LibreOffice on the server" until you rebuilt with LENS_TARGET=full (make run already used it). LENS_TARGET=lean still builds the smaller one.
  • Set up the first admin without copying the code. The API log now prints a link next to the setup code (…/setup?code=…, from FRONTEND_URL) that opens the setup page with the code filled in and the cursor in Name. Without the link, the page offers make setup-code with a copy button; it named a lens container that no install uses.
  • First start on a fresh database. docker compose up on an empty database could stop the API with "Database index space_name already contains 'podcasts'": the API and the worker both created the configured namespaces at once. The one that loses now uses the other's.
  • The dev web app no longer breaks when the API schema is rewritten twice at once. The Docker dev stack's watcher regenerated the API client on every change to openapi.json, and two generations at once deleted each other's files ("Module not found: Can't resolve '../core/auth'"). It now runs one at a time and runs once more for changes made during a run.
  • Calendar feeds on your own network. A calendar server at home or on an intranet (Nextcloud, Radicale) was refused with "only public web pages can be captured", and Docker and the packages had no way to allow it. LENS_WEB_NETWORKS in .env (e.g. 192.168.1.0/24) now adds networks to documents.web_networks, and the error says so.
  • Search by meaning. Search now also finds moments about what you asked in other words: "money worries" finds "we can't afford the rent this month". A new embed pipeline step (in the standard pipeline, after analyze) has an OpenAI-compatible embedding model embed each recording's passages (runs of transcript lines, page text, and what shots and pages are described as showing) into SurrealDB's HNSW vector index; by default the LLM provider's server with nomic-embed-text, which runs offline in Ollama, or a server of its own (Settings → Search, embeddings in archive.yaml, LENS_EMBED_*). Searches fuse the keyword (BM25) hits with the passages found by meaning by reciprocal rank; a query with "phrases" or OR stays exact, and the web app's Match switch (Words and meaning, Words only, Meaning only; mode on GET /search) chooses. Moments found only by meaning are marked Related, with how alike they are, and count in the facets. The assistant's search tool and chat's retrieval find passages by meaning too. Only changed passages are embedded again (after corrections, splits, merges and entity renames). A seeded hourly routine, Index for search by meaning, indexes recordings made before it was set up, or after the model changes (which drops the old vectors); Settings → Search tests the model, shows how much is indexed and can queue more now, and lens embed indexes from the command line (docs/processing.md#search-by-meaning).
  • Audio and video are transcribed out of the box in Docker and the packages. The images (and the Proxmox install) had no transcription engine, so every import failed at Transcribe with "SenseVoice needs FunASR". They now carry faster-whisper, and a worker whose configured engine isn't installed transcribes with one that is, saying so in the run's log.
  • Imports no longer wait forever at "shots" on a native setup. archive.yaml written from the example before the video steps existed lists no shots, ocr, faces, objects or describe in workers.steps, so no worker took them. A worker with such a list now runs them too, a worker whose list leaves steps out says so when it starts, and the example lists every step.
  • Watched folders and routines work in Docker and the packages. They were checked only by the API's own background work, which Docker, Synology, QNAP, Cloudron and Proxmox all turn off in favour of a lens worker process; so folders were never scanned and routines never ran. lens worker now does both (not a worker limited with --steps, nor one started with --no-schedule), and a folder scan is claimed first, as routine runs are.
  • Security: backend dependencies patched. FastAPI moves to 0.142 and Starlette to 1.7, which fixes the Host-header URL, multipart, Range-header and form-limit advisories; cryptography moves to 50 (its bundled OpenSSL and the PKCS#7 and certificate-chain advisories) and pytest to 9.0.3. Secrets sealed before the upgrade still open (AES-GCM is unchanged). API validation errors now include the input and ctx fields, and responses carry Vary: Origin alongside Vary: Authorization.
  • MCP server: agents search, read and cite the archive. Add https://<your Lens>/mcp to Claude, Cursor, VS Code or another MCP client, sign in on Lens's consent page (OAuth), and the agent sees what you see: your namespaces, the collections you were given a role on, and their graphs. Read-only tools: search (moments said, on screen or written, with links to each), list_namespaces, list_recordings, list_speakers, get_recording (summary, chapters, entities), get_transcript (paged), fetch (the whole text), cite (the words, who said them, the time and a link, as Markdown), list_entities, get_entity, explore_graph and find_path. Links open the recording at that moment in the web app. Streamable HTTP without sessions, in both protocol eras (the 2025 initialize handshake and 2026-07-28's per-request envelope); an API token works for clients that can't sign in (docs/mcp.md). An app's token keeps the server it was asked for (resource, RFC 8707), and the MCP server refuses one asked for another.
  • Opt-in telemetry. Lens can send OpenTelemetry traces and metrics about its own work to a collector you choose: API requests by route, jobs and each step, routines, workflow runs, and model calls with their tokens and an estimated cost (from per-model prices you set). It is off by default and never on unless you turn it on, in Settings → Telemetry, the setup wizard's new last step, or LENS_TELEMETRY=on with LENS_TELEMETRY_ENDPOINT in .env (LENS_TELEMETRY=off keeps it off). It goes only to that endpoint, over OTLP/HTTP; there is no built-in destination. Spans and metrics carry ids, step types, model names, counts and timings, never transcript, prompt or answer text, file names, titles, people, paths or addresses. Settings shows whether it is on and how the last exports went, and can send a test span; the API and each worker follow a change without a restart (docs/telemetry.md).
  • Security: the API client generator is upgraded. @hey-api/openapi-ts moves from 0.83 to 0.99, which removes the critical handlebars and tar alerts it brought in and fixes a prototype-chain issue in the generated client itself. make openapi regenerates app/openapi-client as before. The client keeps its old behaviour of failing loudly when the API can't be reached (throwingNetworkErrors in lib/api/client.ts), so an API outage still shows as an error rather than as "Wrong email or password".
  • Security: frontend dependencies patched. The web app's lockfile now pulls fixed versions of form-data, ws, brace-expansion, minimatch, picomatch, glob, js-yaml, flatted, browserslist, Babel and the other packages GitHub flagged, each kept inside the major version its parent asks for (overrides in nextjs-frontend/pnpm-workspace.yaml). The unused @hookform/resolvers dependency is gone.
  • Install on Proxmox VE with one command. A community helper script (proxmox/ct/lens.sh, run in the Proxmox host's shell) creates a Debian 13 LXC container running Lens without Docker: SurrealDB 3.2.4, the API, a job worker and the web app as systemd services, with fresh secrets and the web app on port 3000. lens-setup-code in the container prints the first-admin setup code; after the admin, the setup wizard (/welcome) asks for the first namespace, the model provider and storage. update moves to the latest published GitHub release (the newest main until there is one), building it beside the running version so Lens stays up until the switch (proxmox/README.md).
  • Synology package. packaging/synology/build.sh builds a self-contained .spk for DSM 7.2.1+ (x86_64, or 64-bit ARM with ARCH=armv8): Manual Install it in Package Center, answer a short wizard (port, address, setup code), and Lens runs. The package carries the API/worker, web app and SurrealDB images and hands them to Container Manager as a project through DSM's docker-project resource, so nothing is pulled and no container is set up by hand. Data, the database and the generated secrets live in a lens shared folder that upgrades and uninstalls leave alone; an upgrade removes the previous version's images. Mail can be set in the wizard, and any MAIL_* or LENS_* line in the folder's lens.conf reaches the API and the worker. Publishing a GitHub release builds the packages for both architectures and attaches them to it (the Synology package workflow, which also runs by hand from the Actions tab; packaging/synology/README.md).
  • Notifications to chat and webhooks. A namespace's owners can have Lens tell a chat room or another app when a run finishes or fails, a batch run finishes, or something is added: on the namespace's page under Notifications, with a test button and a list of what each target was sent (docs/notifications.md).
    • Targets: Matterbridge's API (and through it Slack, Discord, Matrix, Telegram, IRC and the rest), webhooks with Lens's own JSON signed the Standard Webhooks way, and Slack-style and Discord incoming webhooks. Addresses, secrets and tokens are sealed in the database.
    • A batch run is one message when it finishes, not one per run; more than five things added at once are one message too.
    • The notifier runs wherever background work does (inline workers, lens worker): it reads runs that ended and recordings added, claims each event once across processes, and retries what doesn't get through (30 s, then four times longer each time, up to 6 hours). Nothing from before notifications were set up is sent.
    • Targets reach public addresses only unless an admin lists a private network under Settings → Notifications (notifications.networks), which a Matterbridge on the LAN or the Docker network needs; the address that was checked is the one connected to, and redirects aren't followed. notifications.app_url sets where links point.
    • GET/POST /api/v1/namespaces/{name}/notifications, PATCH/DELETE …/{tid}, POST …/{tid}/test, POST …/{tid}/secret and GET …/{tid}/deliveries (docs/api.md).
  • Email and calendars as sources. Sources can now be an email account (IMAP) or a calendar feed (iCal), next to S3, Drive, SFTP and the rest. Browse them, import chosen messages or events, or watch them like a folder (docs/processing.md#email-imap-calendar-feeds-ical-and-google-calendar).
    • IMAP: mailboxes are folders and messages are .eml files named by their subjects. Each message becomes an email document, titled by its subject and dated when it was sent, and its attachments are kept and become resources of their own. Watching the whole account skips the bin, junk, drafts and Gmail's All Mail, Starred and Important, and a message in several mailboxes comes in once. A watch asks only for messages newer than the last it saw. Lens only reads: messages stay unread and nothing is moved or deleted.
    • iCal: an https:// or webcal:// address, with a password if it needs one. The address is kept encrypted like a password, fetched only from public addresses (and networks allowed in documents.web_networks), and the password isn't sent on to another server the calendar redirects to. Each event becomes text (title, when and where, organizer and attendees, how it repeats, description), dated when it starts; Outlook's Windows time zone names and calendars' own time zones are understood. An event that changes is read again into the same resource instead of making a second one.
    • .eml and .ics files can be read as text everywhere else too: in a storage source or uploaded through Import. Where the server can't make PDFs, an email from a source is read as text instead of being skipped.
  • Routines: things the archive does on a schedule. A routine runs its actions over chosen namespaces (or all) on a cron schedule in a time zone, or when someone presses Run now: sync watched folders, queue a pipeline for new, unprocessed or all recordings, or run a workflow. Runs keep per-action results and a log (docs/processing.md, Routines). GET/POST/PATCH/DELETE /api/v1/routines, POST /api/v1/routines/{id}/run, GET /api/v1/routines/{id}/runs, GET /api/v1/routines/schedule to preview a schedule.
    • The API process checks for due routines every 30 seconds in its own thread, and lens watch runs them too. A run is claimed before it starts, so two processes never run the same routine twice; a run that stops reporting for three hours is taken as dead.
    • "New recordings" means recordings made since the routine last looked, by recording id, so nothing made during a run is missed or taken twice.
  • Organising the graph with a workflow. Workflows have a scope: recordings (as before) or the graph. Graph workflows find look-alike entities in each namespace and across shared ones, ask the model whether each pair is one thing, and merge or link the pairs it is sure of while proposing the rest. Every change is recorded and can be accepted, dismissed or undone (/api/v1/graph-changes), and a whole run can be undone at once. A fresh archive gets the workflow Organise the entity graph and a nightly routine that runs it, switched off.
    • Graph workflows can't be attached to pipelines or run on one recording; recording workflows can't use graph nodes.
    • Web app: Routines (admins) lists routines with their schedule, next run and last result, and turns them on and off; the editor has schedule presets with a live preview of the next runs, namespaces, and an ordered list of actions. Each routine's page shows its runs with results and logs, and undoes a run's changes. Proposed changes (/routines/changes, also linked from the Graph page) shows both entities side by side with the model's verdict, to merge, link, dismiss or undo. On the canvas a new workflow can organise the graph, with its own nodes.
  • Workflows on a canvas, and content types. Pipelines make assets (transcripts, shots, OCR text, faces); workflows are shared, versioned node graphs that make metadata (outputs, custom field values, entities). Both are drawn on a canvas (Pipelines → Workflows, and Canvas on a pipeline): drag nodes, connect them, set each node's options, save a version. A pipeline runs a workflow as a workflow step, pinned to its version when the run is queued.
    • Workflow nodes: input, LLM template, pick, condition (yes/no), merge, output, custom field, and entity extraction as nodes (by rules: the built-in extractor, terms and patterns; by the model, as structured output; save entities). /api/v1/workflows lists, creates, versions and runs them.
    • Every resource is video, audio, image or text, and under each is an editable vocabulary of content types (podcast, interview, screen-share tutorial, email, ...) that recognise files by extension, name and length. A content type can have a pipeline, and a namespace can override it. /api/v1/content-types.
    • Nothing changes until someone sets something: the standard pipeline is drawn as a chain of today's steps, and content types start without a pipeline.
  • Releases are automatic, from Conventional Commits. PR titles are Conventional Commits (feat(chat): …, fix!: …), checked on each PR and, with make hooks, on each commit message. Running Release from the Actions tab picks the semantic version from the titles merged since the last release (or takes patch, minor, major or a version), writes the changelog section from them under any hand-written Unreleased text, sets the version in the backend, frontend and Cloudron manifest, commits and tags vX.Y.Z, and publishes the GitHub release with that section as its notes; a tag pushed by hand is published the same way. Publishing starts the Cloudron, QNAP and Synology package builds, which attach to the release. It replaces the template's draft-only workflow (docs/contributing.md).

    • The pre-commit hooks also check YAML, TOML and the GitHub workflows (actionlint), and the commit message.
    • The repository has a CONTRIBUTING.md, SECURITY.md (report vulnerabilities privately to the maintainer), a code of conduct (Contributor Covenant 2.1), bug and feature issue forms, a PR template that asks for a Conventional Commit title, and Dependabot updates for Python, JavaScript, Docker images and Actions, titled build(deps): … so the changelog lists them under Dependencies.
  • First-run setup wizard. A fresh install now walks its first admin through setup in the web app: after the admin account (still with the one-time setup code, so a stranger can't claim a public server) come the first namespace, the model provider (with a connection test) and storage (the upload limit, and a folder to watch). Every step and the whole wizard can be skipped; installs that already had accounts never see it (docs/configuration.md#first-run-setup).

    • Or answer it in .env: LENS_ADMIN_EMAIL / LENS_ADMIN_PASSWORD create the first admin at startup, LENS_NAMESPACE the first namespace, LENS_LLM_BASE_URL / LENS_LLM_MODEL / LENS_LLM_API_KEY set the model provider, and LENS_SETUP_WIZARD=off turns the wizard off. Environment values win over the app, and the wizard and Settings show them locked; a key from the environment is never shown.
  • Fix: a fresh database with no namespaces in archive.yaml no longer stops the API from starting with "table 'seq' does not exist" (SurrealDB 3). The counters table is defined with the rest of the schema now, so nothing reads it before it exists.

  • Runs on Cloudron. The repository is now a Cloudron package (CloudronManifest.json, Dockerfile.cloudron, cloudron/): cloudron install --location lens -f Dockerfile.cloudron from a checkout installs Lens on your own Cloudron, which builds the image itself (cloudron/README.md).

    • One app runs SurrealDB, the API, a job worker and the web app; secrets are made on first start and kept in /app/data, password-reset mail goes through Cloudron's mail server, and the first-admin setup code is in the app's logs.
    • The database is exported to /app/data/backup/lens.surql every six hours, so Cloudron's backups always hold a consistent copy alongside the live files.
    • It can also be published as a community app (cloudron versions add), with the image built and pushed to GHCR by the new "Cloudron image" workflow on every published release.
  • Lens for QNAP NAS. packaging/qnap/build.sh builds a self-contained QPKG (with QDK) for Intel/AMD or ARM models that installs from the App Center's Install Manually: it carries the Lens images and SurrealDB, loads them into Container Station on its first start, and runs the stack there, so there are no containers to set up by hand. The first start writes the settings with fresh secrets to a data folder (/share/Container/lens) that outlasts removing the app, and puts the first-admin setup code in the QTS system log; Lens sees only Multimedia/Lens on the NAS, a folder the setup steps can watch. Upgrades install over the old version and remove its images. Publishing a GitHub release builds both packages and attaches them to it (.github/workflows/qnap.yml). Steps in packaging/qnap/README.md.

  • Fix: Chat answers no longer break off with "The answer stopped before it finished". With some model servers an answer ended mid-stream with nothing saved, so the question sat unanswered in the conversation. Now each one ends in an answer, or an error that's shown and saved with the conversation.

    • Tool calls whose arguments come as an object rather than a JSON string (some Ollama, llama.cpp and vLLM setups) are read as they are; they used to break the answer.
    • A reply that isn't JSON (a proxy's error page), a stream that's cut off, and a tool that fails are errors the chat says in words: the model is told about a failed tool and carries on.
    • Reasoning models (Qwen 3, DeepSeek R1) no longer show their <think>…</think> block in answers, summaries and the source check.
    • In a conversation scoped to some recordings (a recording's Chat tab, a collection's Chat) the assistant's search looks only there, so matches elsewhere no longer crowd out the ones in scope and leave it with nothing.
    • Tool steps say "not found" instead of a bare id, and an entity change missing its new name, type or merge ids is refused instead of asking you to approve "Change X to None".
  • Faster to run. make run builds the images once and runs the whole stack without hot reload (docker-compose.prod.yml, with a .env of fresh secrets written on first use): the web app is built ahead of time, so every page opens at once instead of compiling on its first visit. make dev is the hot-reload stack; make stop, make logs and make setup-code go with both (docs/get-started.md).

    • No .env files to copy: make run, make dev, make start-backend and make start-frontend write the ones they need when missing (make env writes them all), with fresh secrets in the root .env that the per-app files share, so Docker and native runs see the same sessions and stored credentials. Running without Docker defaults to the embedded database with workers inside the API process. Nothing is overwritten.
    • The web app's dev server runs on Turbopack now (about twice as fast to compile a page as webpack here); the production build stays on webpack. The word-being-said highlight (::highlight(lens-word)) is registered from the transcript at run time rather than in globals.css, which Turbopack's CSS parser rejected; the webpack type-check plugin is gone (type errors come from the editor, pnpm tsc and CI).
    • The backend's OpenAPI watcher no longer runs mypy on every save (it took longer than the reload) and leaves openapi.json alone when the schema hasn't changed, so the frontend doesn't regenerate its client for nothing.
  • OAuth sign-in for API and MCP clients. Apps can sign people in with their Lens account instead of asking them for a key: Lens is an OAuth 2.1 authorization server with PKCE (S256, required), dynamic client registration (RFC 7591) and discovery (/.well-known/oauth-authorization-server, /.well-known/oauth-protected-resource), as MCP clients expect. Decided with the project owner: Lens issues the tokens itself; they carry the person's roles and last as long as the token-lifetime settings say; no account is created through OAuth.

    • API: POST /api/v1/oauth/register, GET/POST …/authorize (the consent page's question and answer), POST …/token (authorization code and refresh token; refresh tokens rotate, and one that comes back after it was swapped ends the access), POST …/revoke, GET …/grants and DELETE …/grants/{id}; new oauth_client, oauth_code, oauth_grant and oauth_token tables, holding only hashes of codes, tokens and app secrets (docs/authentication.md#oauth, docs/api.md#oauth). The bearer path takes an app's access token (lo_…) next to sessions and API keys; GET /auth/me says via: "oauth". Audited as oauth.client.register, oauth.grant and oauth.revoke.
    • An app asks for read or read write, and the person may give read only. An admin's app has the admin's roles in every namespace but can't administer the archive (people, settings, the audit log, everyone's keys).
    • New settings (Settings → API keys, docs/configuration.md#api-keys): tokens.oauth_access_minutes (60) and tokens.oauth_refresh_days (30, at most tokens.max_days).
    • Redirect addresses are https, this machine's (any port) or the app's own scheme; ones with a user name or a backslash in them, and the browser's and the system's own schemes, are refused. Discovery names the web app's address only as a trusted proxy reports it, else FRONTEND_URL.
    • Web app: the consent page at /oauth/authorize (signing in first when needed) names the app, where it returns you to and what it asks for, with Allow and Deny; API tokens → Apps with access lists the apps you allowed, with Revoke; /.well-known/… is served on the web app's address too. The web app now passes the address the browser used (its Host, or what a reverse proxy in front reports) on to the API.
    • A code that comes back after it was swapped ends the access it gave; a rotated refresh token that comes back within a minute is refused without ending anything. Expired codes are swept. A 401 asking for a token names the protected resource metadata (WWW-Authenticate: Bearer resource_metadata="…"). The consent page can't be framed.
    • The web app passes a browser's own X-Forwarded-Host and -Proto on to the API only with TRUST_PROXY_HEADERS=true (a reverse proxy in front sets them); otherwise it reports the Host it was sent.
    • Checked in the browser, playing the app: it finds the endpoints on the web app's address and registers; a signed-out viewer is sent to sign in and comes back to the consent page, switches "Make changes" off and allows; the app gets its code with its state, swaps it for tokens that have the viewer's one role, and renews them; on a phone in dark mode the viewer sees the app under Apps with access and revokes it, and its token stops working; a request with an address the app didn't register says so and sends nobody anywhere; an admin in dark mode denies (the app hears access_denied), then allows: the app reads resources and can't read the audit log; Settings → API keys shows the two lifetimes. No console errors besides the 400 of the refused request.
  • Fixes for the Docker stacks. From running make dev on an Apple Silicon Mac with Colima and reading its logs.

    • make finds Compose by itself: docker compose, or the standalone docker-compose where there's no docker plugin (Colima, Podman); DOCKER_COMPOSE=… still chooses.
    • The dev stack's mail catcher is Mailpit (http://localhost:8025, as before): MailHog publishes no arm64 image, so the stack didn't start on Apple Silicon. Its ports are published on this machine only (127.0.0.1).
    • Containers stop when told to. The API's and the web app's dev containers and the worker ignored docker stop (as a container's first process, bash and Python ignore TERM unless they handle it) and were killed ten seconds later (exit 137): start.sh now passes the stop on to the server and the watcher, and lens worker and lens watch stop on TERM as on Ctrl-C.
    • Dependencies follow the image. The dev containers kept their packages in named volumes, which a rebuild doesn't touch: after pnpm-lock.yaml moved to Next 16.3.6 the web app still ran 16.0.8. They're anonymous volumes now and make dev renews them (up --build --renew-anon-volumes).
    • The web app's dev server no longer writes an AGENTS.md and a CLAUDE.md into nextjs-frontend/ (Next 16.3 does unless told not to), and its browser-support data is current (the logs said it was 24 months old).
    • A dev container whose server crashed stops (the log says why) instead of looking up and serving nothing.
    • make run no longer overwrites the dev images: the production ones are lens-backend and lens-frontend, tagged ${LENS_VERSION:-prod}. A plain docker compose up after make run ran the production web app, which has no dev build, and crashed.
    • The dev stack reads ARCHIVE_SECRET_KEY from the root .env, as native runs do, so both read the same stored credentials. Upgrading: a dev stack that stored credentials before this sealed them with the key in its volume's data_dir/secret.key and can't read them now: enter them again, or set ARCHIVE_SECRET_KEY in .env to that file's contents (docker compose exec backend cat /data/secret.key).
  • Fix (security): only the server's own media links are signed. Text shaped like a media link (/api/v1/recordings/12/audio) came back signed: titles and transcript lines in API responses, and anything in the embed and report pages, including the transcript data inside them. Someone who could rename a recording or correct its transcript could get a working link to the audio, video or frames of any recording, in namespaces they have no role in. Responses now sign only the fields that hold links; the embed page signs only its recording's links, and a report page only those of its namespace's recordings.

  • Library: filters, sorting and counts on the server. The Library's filters, tabs and sorting now look at every recording you can read, not just the rows loaded so far, and the page no longer says it can't. GET /api/v1/recordings takes q, status (the recording statuses plus the job states processing and failed), attention, processing, speaker, from/to, min_duration/max_duration, media and sort, and says how many recordings match in the X-Total-Count response header (documented in docs/api.md).
    • The body is still the list of rows, so existing API clients keep working; the total is a header rather than a new envelope for that reason (it is exposed to cross-origin browsers too).
    • from/to are days (both included) and durations are seconds, so the API doesn't bake in the web app's ranges.
    • speaker takes speaker ids. Speakers belong to one namespace, so the web app's speaker filter lists everyone who speaks in the namespaces in scope by name and sends every id with that name.
    • The Needs attention and Processing tab counts come from the server too.
  • Whole job logs. A run's Activity page shows its whole log, streaming as it's written, and downloads it all; it was the last 200 lines. Runs keep up to 100,000 lines in a new job_log table, written in chunks every couple of seconds; the job still carries its last 200 (log) and now how many there are (log_total). GET /api/v1/jobs/{jid}/log?after=&limit= pages through them, and GET /api/v1/events?logs=<jid> follows one job with log events {job, start, lines} (docs/api.md). Runs from before this keep their last 200 lines.
  • Documents and images. A PDF or an image is a resource of its own: its pages are drawn and their text read, so it can be looked at page by page, searched, summarised and asked about like a recording. Decided with the project owner: a PDF is a document unless the person importing it chooses a transcript; images are shown, read by OCR and published like the rest.
    • Import → Upload takes PDFs and images (JPEG, PNG, TIFF, WebP, GIF, BMP) in pieces, like audio and video; a PDF asks "Import as: Document / Transcript". uploads.extensions lists them by default: a list saved in Settings → Uploads before now needs them added.
    • The pipeline's transcribe step draws a document's pages (poppler) and reads each page's text with where each block is on it; pages with hardly any text (scans) and images are read by OCR (video.ocr_engine). A TIFF's frames are its pages. New settings: documents.page_pixels, thumb_pixels, ocr_below_chars and max_pages (docs/configuration.md#documents-and-images). The Docker image has poppler and Tesseract; CI installs poppler now too.
    • The resource page shows the pages (thumbnails, zoom, ←/→ to turn) beside the text by page: choosing a block, a find match, a summary point or a chat source turns to its page and marks the block on it. Editors correct what was read ("Correct text"). Summaries, chat, notes and history say pages ("p. 3"), not times.
    • Library: a Kind filter (audio, video, transcript only, document, image), and rows give a document's pages instead of a duration. Search finds the text on its page and opens there (/resources/<id>?page=3).
    • API: source/media_kind document and image, pages on list rows and in the player data, p and b on its segments, search hits with source: "page", page on summary items and chat sources, GET /resources/{rid}/media as the file to save (docs/api.md#documents-and-images). Answers "OCR for scanned PDFs" (docs/backend-gaps.md).
  • Documents and images: published, from storage sources, and their faces. Documents and images go everywhere recordings go.
    • IIIF: a document's Manifest has a Canvas per page with its page drawn (/iiif/<id>/pages/<n>.jpg) and its text as annotations on the page where each block is; search hits target their block; downloads are its text and the file itself. With the media closed, each page has its own Authorization Flow probe. schema.org and Dublin Core call it a document or an image (docs/iiif.md).
    • Public pages: visitors see a document's pages (turning them, thumbnails), its text page by page with find, its sections by page and its file to save, in the parts open to them; an image shows as one picture. Public search hits say their page ("p. 2") and open there (?page=2). GET /public/recordings/{rid} gives media.pages and the text's pages; hits have page (docs/api.md#public).
    • Storage sources: a source's PDFs are documents and its images images, staying on the source like audio. Import → From a source lists them, can import them, and asks whether PDFs are documents or transcripts (pdf_as, default document). Watched folders get two more kinds: documents and all, which is now the default. Existing watched folders keep what they picked up (audio, transcripts or both, which read PDFs as transcripts). Previews count documents and images.
    • Faces: where a namespace looks for faces, the faces step looks at a document's pages too. The People tab says which pages someone is on ("On p. 1–2") and turns to them, and their boxes show on the page. Pages a face isn't on aren't bridged, and the namespace's faces don't count pages as time on screen. Turning faces on with reprocessing queues documents and images too.
    • Checked in the browser: an admin imports a PDF and a scanned letter from a local source (the PDF as a document), the Sources page counts them and a new watched folder picks up everything; a face on the document's pages shows on the page and in People; a visitor reads the public page, finds "lighthouse" on page 2 and follows a public search hit there; on a phone in dark mode a visitor reads the document and the image, and a viewer sees the people on its pages. No console errors.
  • Web pages captured as documents. Give a web address and Lens keeps the page as it is then, as a PDF, and reads it like any document: a link to a PDF as it is, any other page printed by headless Chromium with its scripts run. Decided with the project owner: headless Chromium, public addresses only.
    • POST /api/v1/import/web {url, namespace, title?, collection?, pipeline?} (editors; audited as import.web); the resource's web says where it was, where it ended up, when and how it was kept; its title becomes the page's own unless one was given. Capturing happens once: transcribing again keeps the captured page.
    • Only public addresses on ports 80 and 443 are reached: checked when the address is given, and every request the page makes is checked again by the proxy inside Lens, which resolves each host once and connects only to the address it checked (no_proxy in the environment doesn't let a request around it). Chromium looks up no names itself and its WebRTC may only use the proxy, so a page's script can't send UDP to an address around it (full Chromium, as in lens:full, takes that from its profile and ignores the switch the headless shell takes: both are set); a redirect to anything but http or https isn't followed. documents.web_networks (startup only) can add an intranet's networks.
    • Chromium runs without its own sandbox only where it can't have one (as root, or where the system doesn't allow it, as in most containers: see Deployment), found once by printing an empty page, never because of a page. It's driven over its DevTools pipe, as Puppeteer and Playwright drive it, not by --print-to-pdf, which some builds never finish (Chromium 154's headless commands all hang): it prints once the page has loaded and gone quiet. It reaches no D-Bus: Chromium asks D-Bus services things on its main thread and waits for the answers, and nothing on the server's buses is a page's business. Nothing else it could wait on is left on (a keyring, the crash reporter, casting), and when it can't print a page the error says what it last said and what the page asked for. This applies to documents made into PDFs too.
    • The PDF is named after the link or the page's title (harbour-news.pdf), in data_dir/web/<resource>/.
    • Web app: Import → Web page (disabled with the reason where the server has no Chromium); a captured page says "Captured from " and links to it, and Details shows its address, when it was captured and how.
    • Checked in the browser: in Import → Web page an admin is told that a bare word isn't a web address and that a private address can't be captured, then captures a page on a test site (allowed by documents.web_networks); it's titled by the page, its text includes the line the page's script wrote, the header links to the page, Details shows its address and that it was printed, and Files names its PDF harbour-news.pdf; a viewer on a phone in dark mode reads it and its details. No console errors besides the 400 of the refused address.
  • Objects in videos, documents and images. A new pipeline step, objects, finds what's in a video's frames and on a document's or an image's pages (people, vehicles, animals and everyday things: COCO's 80 kinds) and keeps each kind with where it's seen. Decided with the project owner: YOLOX on ONNX Runtime by default (both Apache-2.0), Ultralytics as an option (AGPL-3.0); what's found can be filtered by and searched for.
    • The step looks at the frames the shots step sampled and at every page. Each kind is kept once per resource with when it's on screen (a frame it was missed in is bridged) or on which pages, how often, how sure, and its boxes. Without a detector it's skipped, saying why (no model, ONNX Runtime not installed, turned off). New settings: video.object_engine (yolox, ultralytics or off) and video.object_min_score, and at startup video.yolox_model and video.ultralytics_model (docs/configuration.md#objects). It's in the standard pipeline, and reprocessing, batches and watched folders can run it.
    • Library: an Objects filter (GET /resources?object=dog; GET /resources/objects says what's been found and in how many resources), kept by saved views. Search: a kind of object is a hit where it's first seen ("Seen on screen", "Seen on the page"), a filter (object=, or object:dog typed) and the Objects seen facet, kept by saved searches (docs/api.md#objects).
    • Resource page: an Objects tab lists each kind with when or where it's seen; choosing one shows its boxes on the frame or the page.
    • Docker: lens:full adds ONNX Runtime (the new objects extra) and YOLOX-s, checked against its hash, at /opt/lens/models/yolox_s.onnx, where Lens looks for it. Settings → Video, OCR, faces and objects says which model is in use.
    • Fix: a step that exits Python (as a speech engine does when a package it needs is missing) now fails its job. It stopped the worker running inside the API instead, leaving the job "running" and the jobs after it waiting.
    • Checked in the browser with YOLOX-s: an admin uploads a photo of a street and a video made of it; the photo's Objects tab lists a bicycle, a car, a dog and a potted plant and shows the dog's box on it; the video's says when each is on screen and shows the dog's box on the frame; the Library filtered by Dog has both; Search for "dog" finds it seen on the page and on screen, and its Objects seen facet narrows to what has a bicycle; Settings shows the detector and its model; a viewer on a phone in dark mode sees the photo's objects and the dog's box. No console errors.
  • docTR reads text too. docTR (Apache-2.0) is an OCR engine for text on screen, scans and images, beside Tesseract, Apple Vision and RapidOCR. Decided with the project owner: docTR as an engine to choose; Tesseract stays the default (auto still picks it first).
    • video.ocr_engine: doctr. It runs on PyTorch: pip install -e ".[doctr]", or EXTRAS="doctr" for a Docker image, which then also gets the libGL its OpenCV needs. Its models are fetched the first time it reads (docs/configuration.md#text-on-screen-and-scans-ocr, a new section on all the OCR engines).
    • Where there's no OCR, the step and a document's unread scans say why: it's off, the engine chosen isn't installed (and how to install it), or its models couldn't be loaded. They said "no OCR engine is available" whatever the reason. A video's Text on screen tab says it too.
    • Settings → Video, OCR, faces and objects offers docTR; its minimum confidence now says what it does (lines read less surely are left out, not kept and flagged).
    • Checked in the browser with docTR 1.1 and its own models: an admin chooses docTR in Settings; a scan uploaded then is read by it, its lines on its page; with an engine that isn't installed (RapidOCR), a video's Text on screen tab says so and how to install it; a viewer on a phone in dark mode reads the scan. No console errors.
  • What pages and shots show. A new pipeline step, describe, 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. Decided with the project owner: the model looks at the picture itself, and only a model an admin chose as able to see images is used.
    • Settings → LLM provider: llm.vision_model, the model that describes (none until one is chosen: Lens can't tell which models see), and llm.describe_max, how many pages or shots of a resource are described (50); each is a request to the model (docs/configuration.md#descriptions). Without one the step is skipped, saying why; a model that turns out not to see makes it fail with what the server said.
    • The player's descriptions lists them; search finds them (source: described, docs/api.md#descriptions); they move and go with their resource. The describe step is in the standard pipeline after objects, and reprocessing, batches and watched folders can run it.
    • Web app: a document's or an image's Text tab says above each page's text what it shows, and its picture says so to screen readers; a video's Shots tab lists each shot beside what it shows (its keyframe's alternative text too); search hits say "What the shot shows" or "What the page shows" and open there.
    • Checked in the browser with the test suite's model server (it "sees" a picture's colours): an admin chooses the model that can see in Settings; a scan's page says what it shows, and its picture says so too; a video of three scenes lists each shot beside what it shows; Search finds "yellow wall" in a shot and opens the video there; a viewer on a phone in dark mode reads the scan's description. No console errors.
  • Faces pixelated for visitors. A namespace's owner can have the faces found in its videos, documents and images pixelated in the pictures visitors see: public pages, embeds, share links and IIIF. Decided with the project owner: for anyone without a role in the namespace, members see the pictures as they are; switched on per namespace, next to face detection.
    • Each face found becomes a few blocks, a margin around it too, on frames, keyframes, pages, thumbnails and face crops. It goes by the faces found, so it needs face detection on in the namespace, and turning faces off turns it off too. PUT /namespaces/{name}/faces/mode {pixelate} (owners, audited); GET /namespaces/{name}/faces and the player say whether it's on (docs/video.md, docs/api.md#video).
    • Links the API signs for members now carry a full=1 mark, signed with the rest, so that pictures fetched by <img> tags (which can't send a bearer token) still come as they are for members; links on public pages, embeds and share links don't carry it. Signed links from before this change work as before, as visitors'.
    • Web app: the People tab has the switch, for owners; others see it with the reason they can't use it.
    • Checked in the browser with OpenCV's YuNet finding the face in a photo: an admin switches pixelation on in the photo's People tab; a visitor's public page shows the face in blocks while the admin's page shows it as it is (the pictures differ, and only the member's link carries the signed mark, which can't be added to a visitor's); a viewer on a phone in dark mode sees the switch and that owners can use it. No console errors.
  • Comments and highlights on resources. Everyone who can read a resource joins a conversation about it in the new Comments tab: select words in the transcript or a document's text and choose Comment, or comment on where the player is or on the whole resource; replies go on the thread, and a thread is resolved (by whoever started it or an editor) and reopened. Editors mark passages in colour: select words and choose Highlight; the passage is marked in the text for everyone, and the new Highlights tab (under More) recolours it, labels it and removes it. Decided with the project owner: readers comment, threaded, on the whole resource or a moment or passage, resolvable; highlights are shared, coloured and labelled, made by editors.
    • GET/POST /api/v1/resources/{rid}/comments, PATCH/DELETE …/comments/{cid} (new comment table) and GET/POST /api/v1/resources/{rid}/highlights, PATCH/DELETE …/highlights/{hid} (new highlight table; docs/api.md). A comment's text is changed only by its writer; its writer or an owner deletes it, replies and all. Highlights come in yellow, green, blue or red, each a token of the web app's; any editor changes or deletes one. Resolving, reopening and deleting are audited.
    • Turns with comment threads get a mark that opens the tab, like notes; a click on a highlighted passage opens it in the Highlights tab. Highlights fall on the words their passage covers (by the line's timed words, or in proportion to its time, the same way a selection's moment is read). The selection toolbar now works on a document's text too (Copy link to the page, Ask in chat, Add note, Comment, Highlight).
    • Comments and highlights move with their resource and go when it's deleted.
  • Word, text, web pages and emails as documents. Any document now becomes a resource with pages, not only a PDF: Word, PowerPoint and spreadsheet files (and OpenDocument and RTF), text and Markdown, saved web pages and emails (.eml, and Outlook .msg with the msg extra) are made into PDFs and read like one. Decided with the project owner: every text-like file is a document unless the person importing it chooses a transcript (subtitles and JSON stay transcripts); an email's attachments are kept as its files and, those Lens can read, also become resources of their own.
    • The transcribe step converts: LibreOffice for Office files; text, Markdown, web pages and emails become a page of HTML, cleaned of anything that fetches or runs, printed by headless Chromium (or LibreOffice). Neither can reach anything while converting: Chromium goes through a proxy inside Lens that serves only that page (with a policy that allows no scripts), LibreOffice through a proxy address that isn't there. The resource keeps its own file; the PDF it's read from is GET /resources/{rid}/pdf and IIIF's /iiif/<id>/pdf.
    • Emails: the subject becomes the title and the date the resource's date; sender, recipients and date are its email. Attachments are its files (role attachment); documents, images, audio, video and emails among them become resources beside it (attached_to), queued for the namespace's pipeline, once only (documents.attachment_resources turns that off).
    • What the server can convert is reported (GET /uploads/limits → convert); uploads it can't read are refused with what's missing, and the web app imports Word, text and Markdown files as transcripts there instead. Storage sources: pdf_as is now documents_as and covers Word and text files; watched folders set to all or documents take text files as documents, those from before as transcripts.
    • Settings → Documents (new): page and thumbnail sizes, OCR threshold, page limit, documents.convert_seconds, attachments as resources, and which converters the server found (documents.soffice, documents.chromium, set at startup).
    • Docker: --target full (LENS_TARGET=full docker compose up) builds lens:full with LibreOffice, Chromium and fonts for most scripts; the default image stays lean. CI installs LibreOffice Writer to test conversions.
    • Web app: the Upload tab names each document ("Word document", "Email") and offers "Import as" for those that can be transcripts; a document's Files tab offers the PDF made of it and opens an attachment's resource; an attachment's page says which email it came from; Details shows an email's sender, recipients and date, and how a document was made into a PDF; the public page offers the PDF and the original.
    • Checked in the browser: an admin imports a Word letter, a Markdown note and an email with a PDF, a photo and a data file attached; each is read page by page; the email's PDF downloads, its PDF and photo open as resources that link back to it, the data file is only kept; the letter's details say LibreOffice made its PDF; Settings → Documents lists the converters; a viewer on a phone in dark mode reads the email's details. No console errors.
  • Custom fields. Editors define their own metadata fields on a namespace or on a collection, for the resources, the collections or the files inside it: text, long text, number, date, yes/no, one or several of a list, or a link. Decided with the project owner: fields live where they're defined and apply to everything inside; each is published (public pages and IIIF) or internal (the workspace only), and new fields are internal.
    • Values are checked by type and saved only where someone may edit the item. A resource's values are part of its metadata history, so a revert puts them back; deleting a field deletes its values (after saying how many). Defining, changing and deleting fields and saving values are audited.
    • Web app: Manage collections → Fields of the namespace…, and Fields… and Describe… on each collection; the recording page's Metadata tab (and the metadata page) and a file's Fields… take the values; the Library filters by a field (Field chip), and saved views keep it.
    • API: /api/v1/namespaces/{name}/fields and the fields of resources, collections and files; field/value on GET /api/v1/resources (docs/api.md#fields). Answers "Custom fields in a namespace profile" (docs/backend-gaps.md).
  • Files: transcripts, captions, translations, indexes, thumbnails and attachments beside a resource. A resource has its primary file (the audio or video its pipeline runs on) and now any number of supplementary files, each with a role, a language, a label and a description. Decided with the project owner: typed files, parsed.
    • Transcripts, captions and translations (text, Markdown, JSON, SRT, WebVTT, Word, PDF) and indexes (WebVTT or SRT chapters, JSON, OHMS XML, or lines that start with a time) are read into lines that search finds next to what was said and shown on screen (source: "file"). Lines play from their times; a file without times opens on its line instead.
    • The recording page's Files tab (under More) lists the primary file and the others to download, shows their lines, and lets editors add, describe, re-role and delete files. Adding, changing and deleting are audited (file.add, file.update, file.delete).
    • A public resource opens its files with its parts (decided with the project owner): transcripts, captions and translations with its transcript, indexes with its index, thumbnails with its media; attachments always need permission. The public page lists the ones a visitor may download.
    • IIIF lists every file as a download (closed ones behind the Authorization Flow), timed transcripts, captions and translations as WebVTT captions, indexes as tables of contents, and a thumbnail as the Manifest's.
    • API: /api/v1/resources/{rid}/files (list, add as the raw body, change, delete, download, lines), up to server.max_upload_mb each (docs/api.md#files). Files are kept in data_dir/files/<resource>/; they stay when a resource moves and go when it's deleted.
  • Resources: /api/v1/resources names what the archive holds. The API's recordings are now its resources: every /api/v1/recordings/… path is published as /api/v1/resources/…, the schema's tag and the generated client's class are resources/Resources, and the web app's pages are at /resources/<id>. Decided with the project owner: the old names stay as aliases. /api/v1/recordings/… reaches the same routes for existing clients and scripts, and /recordings/<id> in the web app redirects (keeping ?t=). Fields keep their names (recording, and the Recording… types), and signed media links the server writes keep their form (docs/api.md#resources).
  • Roles on collections. People can be given a role on a collection (viewer, editor or admin), which holds for the collections inside it too and adds to their namespace role. Someone without a role in the namespace sees just those collections: their recordings in the Library, search and the recordings' pages; the namespace's own pages (Speakers, Graph, Reports, Chat, Activity) stay with its members and say so. An admin of a collection runs it like an owner: its people, its sub-collections, and owner actions on its recordings. Decided with the project owner.
    • In the Library: Collection → Manage collections → People… gives, changes and takes away roles; each collection offers only what you may do with it. A namespace seen in part shows "Some" in the namespace picker.
    • API: GET/PUT /namespaces/{name}/collections/{cid}/members (owners of the namespace, admins of the collection; audited as collection.member); collections say your role, can_change and can_grant; recordings and Library rows carry your role on each; GET /namespaces lists namespaces seen in part (partial) and GET /auth/me names them; search, the recording's runs and entities, notes and IIIF follow (docs/access.md, docs/api.md).
  • Collections inside namespaces. Every recording now lives in one collection of its namespace, and collections nest. Each namespace starts with "General", its default, which took every existing recording. The Library filters by collection (with the ones inside it) and its Manage collections dialog makes, renames, moves, describes and deletes them and picks the default; the bulk bar's Collection moves recordings into one. A recording's breadcrumb shows where it lives and its ⋯ menu moves it. Imports, uploads, source imports and IIIF imports choose a collection, and saved views keep one. Decided with the project owner: one home per recording, nesting, saved collections stay as they are (lists of recordings from anywhere).
    • GET/POST /api/v1/namespaces/{name}/collections, GET/PATCH/DELETE …/{cid} (editors change them; audited as collection.create, .update, .delete), POST /api/v1/recordings/collection (audited as recording.collection), and collection on GET /recordings, recordings, imports, uploads and moves (docs/api.md). A collection is deleted only when empty and not the default, so it never takes recordings with it.
    • IIIF: a namespace's Collection now lists its collections, not its Manifests: each collection is a Collection at /iiif/collection/<namespace>/<id> holding the collections inside it and then its Manifests, and a Manifest's partOf is its collection (docs/iiif.md). Harvesters that only read Manifests at the namespace's level need to follow the Collections inside it. Importing from IIIF does: it now finds the Manifests of nested Collections, so one Lens can still import another's namespace.
  • Fix: Escape in a menu inside a dialog closed the dialog too. Menus used an older copy of the layering that dialogs use, so each thought it was on top; @radix-ui/react-dropdown-menu is now 2.1.24, which shares the dialogs' copy, and Escape closes only the menu.
  • Admins decide how long API keys last. Settings → API keys sets how long a new key lasts (90 days unless changed), the most it may last (365) and whether keys may never expire (not by default; until now anyone could make one that never expires). The API tokens page offers what's allowed. The same section lists everyone's keys, and an admin can revoke any of them.
    • tokens.default_days, tokens.max_days, tokens.never_expire (docs/configuration.md); POST /api/v1/tokens leaves out days for the default, and answers 400 past the limits. GET /api/v1/tokens/limits says them.
    • GET /api/v1/admin/tokens, DELETE /api/v1/admin/tokens/{id} (admins). Revoking a key, yours or anyone's, is audited as token.revoke. Keys made before a change keep their expiry.
  • Summaries: key points and action items say when. The Summarize step now gives key points as well, and each key point and action item comes with the time of the line it's from (and who will do an action item, when said); the Summary tab shows the time, which plays from there, as do the report page and the embedded player.
    • The summary's key_points and action_items are {text, who?, t0?} (t0 in ms; docs/api.md); the model is asked to cite each line's time, and a cited time becomes the start of the line shown with it. Summaries made before this keep plain strings, which everything still reads.
    • In templates, these items print as their text, so templates written for strings keep working; .who and .t0 are there for new ones (docs/processing.md).
  • Split and join transcript lines; the word being said lights up. In Edit, a line splits at the cursor (Shift+Enter, or Split here), optionally giving the rest to another speaker (Split, the rest is…), and joins the next line (Join with next line, or Delete at its end; Backspace at its start joins the line above). Undo takes them back like other corrections, and the change history lists them. When transcription timed the words, the one being said is highlighted as the recording plays.
    • POST /api/v1/recordings/{rid}/segments/{idx}/split {at, t?, speaker?} and …/merge (docs/api.md). The lines after it are renumbered, and the edit history, entity corrections and chapters follow; the words keep their timings; Analyze runs again, as after a correction. Audited as transcript.split and transcript.merge; GET …/edits gives each change's kind, in the order made even within a second.
    • Lines in GET …/player carry their timed words as w (character ranges of the text, with times), so a corrected line keeps the timings of the words it still has. The highlight uses the browser's CSS Custom Highlight API; browsers without it keep the line's tint.
  • Notes on recordings. Select words in the transcript and choose Add note, or write one in the new Notes tab about where the player is or about the whole recording. Notes are yours; editors can share theirs with everyone who can read the recording (decided with the project owner, like saved views). Turns with notes get a mark that opens the tab, and each note's time plays from there.
    • GET/POST /api/v1/recordings/{rid}/notes, PATCH/DELETE …/notes/{nid} (new note table; docs/api.md): text, an optional moment (t0, t1 in ms) and quote, and shared. Only its writer changes a note; its writer, or an owner of the namespace for a shared one, deletes it. Sharing, unsharing and deleting a shared note are audited.
    • Notes move with their recording and go when it's deleted.
  • Chat: draw on collections as they are. Scoping a conversation to a collection keeps the collection, not a copy of its first 200 recordings: the assistant reads its recordings each time it answers, so a filter collection's new recordings count. Several collections can be picked; the scope chip names them.
    • Chat scopes take collections (ids of collections you can see, yours or shared; docs/api.md). They narrow it like the other keys, also for the assistant's tools; a deleted collection adds nothing.
    • /chat?collection=<id> (a collection's Chat) starts a conversation scoped to it.
  • Search: "Try …" whole words for a prefix. Searching interp* finds nothing (there's no prefix search); the page now offers the words said in your namespaces that start with it ("Try interpretability or interpreter"), and picking one searches for it. GET /api/v1/search/terms?prefix= lists them, the most said first (docs/api.md).
  • Search: saved searches keep every filter. Save keeps a search's words and all its filters (namespace, speaker, emotion, recording) in Saved searches; they were kept as filter collections, which can't hold emotion or recording. Like saved views, they're yours, and editors can share one with the namespace it searches; the panel lists yours, then the shared ones, each with a Delete for whoever may delete it.
    • GET/POST /api/v1/searches, PATCH/DELETE /api/v1/searches/{sid} (docs/api.md), kept with saved views as their own kind. Sharing, unsharing and deleting a shared one are audited.
    • "Also keep it as a collection" in the Save dialog still makes the collection, to chat with it or run things on it. Searches saved before this are collections and stay in Collections.
  • Search: filter counts over every match. The Namespace, Speaker, Emotion and Recording counts next to the results cover every moment the words match, not the first 200 the page had loaded. GET /api/v1/search?facets=true returns them (facets, docs/api.md), counted on the server up to 20,000 moments; past that the panel says the counts cover 20,000 of them.
  • Library: Edited by me, and Source and Language filters. The Edited by me tab lists the recordings you corrected (a line of the transcript), catalogued (the metadata record) or renamed, with a count like the other tabs. The Source filter picks where recordings came from: each connected source by name, Uploaded, Pasted text, IIIF imports, Archive folders (lens scan) and Imported files; the Language filter picks their languages, or "Not known".
    • GET /api/v1/recordings takes edited_by=me, origin and language, and its rows carry origin, origin_name and language (not the raw path/remote); GET /api/v1/recordings/origins and GET /api/v1/recordings/languages count them for the filters (docs/api.md).
    • Saved views keep the new tab and both filters.
  • Library: saved views. Save view keeps what the Library shows (its namespace, tab, filters and sort) under a name, and Views brings it back. Views are yours; editors can share one with its namespace, and everyone with a role there then sees it (decided with the project owner). Only its maker changes a view; its maker or an owner of the namespace deletes a shared one.
    • GET/POST /api/v1/views, PATCH/DELETE /api/v1/views/{vid} (docs/api.md), in a new saved_view table. Date and length filters are kept as the Library's ranges, so "last 30 days" stays the last 30 days; the speaker is kept by name and found again in the view's namespace.
    • In Views, your own views can take what the Library shows now, and be shared or unshared. Sharing, unsharing and deleting a shared view are audited.
  • Reports: a namespace's numbers come from the server, for the range you pick. The overview's recordings, hours, speakers, months and top speakers come from a new GET /api/v1/namespaces/{name}/stats?from=&to= (docs/api.md); the page used to load every recording of the namespace (up to 10,000) and count in the browser. Top speakers are now the range's, by talk time in its recordings; they were all-time. The table of recording reports asks for the range's latest 25.
    • from/to are days, both included, like the Library's date filter; months run from the range's start (or the first recording) to its end (or this month), empty ones included.
    • Recordings without a date count only in All time, and in no month; the page says so when there are some.
  • Share links: revoke one, see plays and where it's embedded, short addresses, and a page for dead links. The Share dialog lists each link with how often its player was played and the sites that embed it, and revokes one link (Revoke…) as well as all of them. A new link comes with a short address, /s/<code>, which opens the same player and can be framed like it. An expired, revoked or mistyped link opens a neutral "This link isn't available" page with status 410 instead of a JSON 401, in the frame of a host page too.
    • DELETE /api/v1/recordings/{rid}/shares/{id} revokes one link; POST .../share returns id and short; GET .../shares adds revoked, revoked_by, revoked_at, short, plays, played_at and embedded_on (docs/api.md). Revoking is audited as share.revoke with the link (or how many links).
    • A play is the shared player starting to play, counted once per page load; the page reports it to POST /embed/{rid}/played. The sites come from the Referer's origin when the browser says the player is in a frame (a new share_embed table, up to 50 sites per link). Lens's own pages, such as the embed builder's preview, count neither.
    • Short codes are ten characters without look-alikes, and only their hashes are kept, like the token's; links made before this have none. /s is proxied by the web app like /embed.
    • The 410 page is the same whatever was wrong with the link, so it never tells whether a recording exists; a signed embed link that expired or was tampered with gets it too.
  • Speakers: "not the same", unlink, and who merged. The review queue's Not the same works: the pair is dropped and never suggested again. Other namespaces lists likely matching voices with how alike they are (it read them off the graph, without a score), each with Not the same and Link, and linked speakers can be unlinked. Merge history says who merged, how many recordings moved, and who undid it.
    • POST /api/v1/speakers/{sid}/not-same {with} and POST /api/v1/speakers/{sid}/unlink {with}; GET /api/v1/speakers adds cross (score) and merge by, recordings, segments, undone_by, undone_at (docs/api.md). Pairs said to be different are kept in a new not_same table and left out of suggestions and of the graph's "maybe the same voice" edges.
    • Linking, unlinking, "not the same" and undoing a merge are audited (speaker.link, speaker.unlink, speaker.not_same, speaker.merge.undo).
  • Choose the model in Chat, and try another. The model chip under the question box becomes a menu when there's a choice: the conversation then answers with the model you pick. Each answer offers Try another model, which asks the same question again with a different one, and says which model wrote it.
    • Which models: a new setting, llm.chat_models (Settings → LLM provider), lists them; left empty, whatever the model server lists (GET /models, kept a minute). The configured model is always offered and stays the default (docs/configuration.md).
    • GET /api/v1/chats/capabilities adds models; conversations take and return model (POST /chats, PATCH /chats/{cid}, null for the configured one); POST /chats/{cid}/messages takes model for one answer; messages carry the model that wrote them. A model that isn't offered is a 400; a conversation whose model is no longer offered answers with the configured one (docs/api.md).
  • Reopened conversations keep how answers were made. An answer now keeps the tools the assistant used, any notice, the error when there was no answer, and its source check; reopening a conversation (or reloading) showed only the text. GET /api/v1/chats/{cid} returns steps, notice, error and check on each message (docs/api.md); answers from before this have none.
  • Stop an answer, and know the model, without being an admin. Stop in Chat (and in a recording's Chat tab) now asks the server to end the answer after the piece or tool step it's on, and keeps what came before in the conversation, marked stopped; it used to stop reading, leaving the question without an answer and the model working. Everyone sees whether a language model is set up, and which, before asking; only admins could.
    • GET /api/v1/chats/capabilities (anyone signed in: configured, model, tools, max_steps, check; not the server's address or key) and POST /api/v1/chats/{cid}/stop ({stopping}); the answer stream sends stopped before done, and saved messages carry stopped (docs/api.md).
    • The stop is a flag on the conversation that the answer checks between pieces (every half second at most) and tool steps, so it works whichever server process is answering. A model call already under way finishes first; the web app stops reading after 15 s if the server hasn't ended the answer by then.
  • Runs of one namespace or one batch, from the server. Activity's namespace filter asks the server, so the status counts and the 200 rows shown are that namespace's (they were the newest 200 of every namespace, filtered in the browser), and its menu says how many runs each namespace has. A batch run's page lists all of its jobs; it showed those among the 500 newest jobs of the archive. GET /api/v1/jobs takes batch and namespace, and returns namespaces (jobs per namespace) next to counts; limit goes up to 2,000 (docs/api.md). Live updates keep each filtered list to its own jobs.
  • Pause, drain and resume workers. Activity → Workers lets admins pause a worker (it takes no new runs; the one it has carries on to the end), drain it (it also hands that run back to the queue after the step it's on, so another worker carries on, and stays paused) and resume it. Each card shows the machine's CPU load and the steps the worker finished in the last hour. A run waiting only on paused workers says so.
    • POST /api/v1/workers/{name}/pause|drain|resume (admins, audited as worker.<action>); GET /api/v1/workers adds paused, draining, paused_by, paused_at, load, cpus and steps_last_hour (docs/api.md).
    • A pause lives on the worker's record, so lens worker --name … stays paused across restarts; the server's own workers are named after its process and start afresh.
    • Draining an idle worker just pauses it; a drain that arrives as its run ends doesn't outlive the run.
  • Fix: a busy worker looked silent. Workers only heartbeat between runs, so one on a long step (a long transcription) showed as silent. They now heartbeat every 15 s while they run, whatever the step.
  • What each step of a run did. A run's Activity page shows, for each step, when it started and finished, how long it took, how it ended, its last message, which worker ran it, the outputs it saved (with the template version and model that made them) and exactly its own lines of the log; while a run is active, how long each step usually takes and about how long the run has left. Runs name their pipeline and the version they pinned, in the list too, and Activity's Pipeline filter works. The recording's History reads the same records.
    • Jobs carry pipeline and title everywhere; GET /api/v1/jobs/{jid} adds step_runs, estimates and eta_seconds (docs/api.md). Runs from before this are read from their log, as before.
    • A step with nothing to do now says it skipped, and why (<step> skipped: no LLM is configured), instead of logging a skip as a success; transcribe, diarize, summarize and the video steps do.
    • Estimates come from the last 25 times each kind of step ran, kept in a new step_stat table: per template for template steps (the namespace's report pages and a report template are timed apart), and per minute of recording for transcribe, diarize and the video steps.
    • Retrying a run starts its step records over from the step it retries; a run that fails because its worker went silent records that on the step, and now has a finish time.
  • Fix: History squeezed its runs. With several runs, the recording's History tab shrank each run's card until its steps were hidden.
  • Fix: opening a SurrealDB server database could fail. Opening a connection to a server creates the database if it's new, and two processes doing that at once (workers starting together, or tests in parallel) could hit a write conflict and stop with "cannot open SurrealDB". Opening now retries a conflict, as queries already did.
  • Import chosen files from a source. Import → From a source lets admins tick files and import them now, into a namespace and with the pipeline of their choice, instead of only watching the folder. The listing marks files that are recordings already, and where. POST /api/v1/import/source {source, paths, namespace, pipeline?} answers per file (queued, already, skipped or error) and is audited as import.source; GET /api/v1/sources/{sid}/browse gives each file's imported. A file chosen on purpose comes back even if its recording was deleted.
  • Choose the pipeline at import. Import's "Then run" picks any saved pipeline instead of the namespace's, for uploaded transcripts, pasted text and audio or video uploads. POST /api/v1/import and POST /api/v1/uploads take pipeline (400 for one that doesn't exist, before anything is saved); attaching audio to a transcript runs its own steps, so it doesn't take one.
  • Attach audio to a transcript. A transcript-only recording gets its audio or video from its page ("Attach audio", also in the ⋯ menu on phones), from Import → Paste ("Attach audio…"), or by dropping a transcript together with the audio of the same name on Import → Upload, which now makes one recording of the pair instead of two. The file goes up in pieces like any upload (POST /api/v1/uploads with recording) and becomes the recording's media: the transcript and its speakers stay, the waveform is drawn, speakers are told apart by voice when the transcript didn't name them, and video gets shots, text on screen and faces (docs/processing.md).
    • The steps that need media are added to the recording's job when it has one (the import's, say), after what it still has to do. Workers now read a job's steps again before each step and only finish a job whose steps are all done, so steps added while it runs aren't missed.
  • Upload audio and video. Import → Upload takes audio and video as well as transcripts. Files go up in pieces (uploads.chunk_mb, 8 MB), each written straight to disk on the server; a piece that fails is sent again, an upload can be paused and resumed, and choosing the same file again after a reload carries on where it stopped. When the last piece arrives the file becomes a recording and the namespace's pipeline is queued; a file the namespace already has finds that recording instead of making a second one. GET/POST /api/v1/uploads, GET/PUT/DELETE /api/v1/uploads/{uid} and GET /api/v1/uploads/limits (docs/api.md); audited as upload.
    • New settings (Settings → Uploads, docs/configuration.md): uploads.max_mb (4096), uploads.extensions (the types folder scans import), uploads.chunk_mb and uploads.expire_hours (24: unfinished uploads are then deleted). The transcript limit, server.max_upload_mb, is unchanged.
    • Uploaded files are kept in data_dir/uploads/<namespace>/, and an upload is refused (507) when it would leave the disk less than 512 MB free. Deleting a recording leaves its uploaded file, as with any media file.
    • The Import page reads its limits from the server for everyone, not only admins.
  • Your own name and password. Everyone changes their own name on the Profile page, and their password with their current one: PATCH /api/v1/auth/me and POST /api/v1/auth/password (signed in, not with an API token; wrong guesses are throttled like sign-ins; audited as password.change). A new password ends your other sessions and keeps this one; your API tokens and this device stay signed in. The reset link stays for a forgotten password.
  • Tags on recordings. Editors tag recordings from the Library's bulk bar (add tags, or take off ones they have); the Library filters by tags and shows them in a Tags column. PATCH /api/v1/recordings/{rid} takes tags, POST /api/v1/recordings/tags changes several at once, GET /api/v1/recordings/tags lists the tags in use, and GET /api/v1/recordings?tag= filters (any of the tags, ignoring case) (docs/api.md).
  • Move recordings to another namespace. Owners move recordings from the Library's bulk bar to a namespace they edit: POST /api/v1/recordings/{rid}/move, audited as recording.move (docs/api.md).
    • They keep their transcripts, media, outputs, permissions and share links; a checkbox stops the share links working.
    • Their IIIF manifests stay as they were: the access, open parts and metadata defaults they had from their old namespace are pinned on them wherever the new one would change them (kept in their metadata history).
    • Speakers and faces are matched by name in the new namespace, or start there with each recording's voice and face; a checkbox identifies them again from their voices instead (audio only). Analysis runs again there, so entity mentions (and corrections to them) start over.
    • Report pages and exports move to the new namespace's folders; the old namespace's scans and watched folders don't import the files again, and its IP groups no longer open them. 409 when the new namespace already has the same file, or while a job is running.
  • Delete recordings. Owners delete recordings from the Library's bulk bar, after a confirmation that lists them; DELETE /api/v1/recordings/{rid}, audited as recording.delete (docs/api.md).
    • Everything Lens made from a recording goes with it: transcript, analysis, frames, outputs and reports, shares, permissions and requests for access, and its place in IP groups, fixed collections, chat scopes and batch runs that haven't reached it. Unnamed speakers and faces only it had go too; named ones stay.
    • The media file stays where it is, and isn't imported again: scans skip the same path or the same file elsewhere, watched folders skip the same remote file. Importing it on purpose brings it back.
    • Its waiting jobs are cancelled; a running job holds the delete back (409), and a worker whose recording is gone stops. A deleted recording's failed jobs can't be retried, batch runs continue without it, and undoing an entity merge no longer brings back mentions in deleted recordings. Public recordings show up as a Delete in IIIF change discovery; the namespace's report overview is rewritten in the background.
  • Rename recordings. Editors rename a recording from the pencil next to its title (or ⋯ → Rename on a phone): PATCH /api/v1/recordings/{rid} with title, audited as recording.rename. The report page is renamed with it, so its link keeps working, and a report job rewrites the title inside; IIIF harvesters see an Update.
  • Access: public, restricted or private (after Aviary's roles and permissions matrix; docs/access.md). Every recording is public, restricted or private, following its namespace's default unless it sets its own; a public one chooses which parts anyone may use (media, transcript, index) and can be featured. This replaces the four IIIF-only access levels.
    • Converted on first start, once: public stays public; transcript becomes public with the media closed; signed-in becomes restricted; private stays private. Namespace defaults convert the same way. Restricted recordings aren't published in IIIF (the old signed-in ones were listed), so the conversion announces a Delete for each in the change feed.
    • Publishing is for owners, as the web app already said: changing a recording's access, open parts or featured flag needs the owner role, through PUT /api/v1/recordings/{rid}/access, metadata edits, bulk edits and reverts alike (editors got a 403 only from the web app before). Changes are audited as recording.access.
    • GET /api/v1/recordings/{rid}/access; the recording list and detail carry access, open and featured, and the list filters by access and featured.
    • The web app shows a recording's access next to its status (click it to change it), marks public and restricted recordings in the Library, and the IIIF panel, metadata editor, metadata profile and Publish dialog use the new setting.
  • A recording's public page, /explore/recordings/<id>, for anyone, signed in or not (Aviary's resource detail page). Visitors see a public recording's description and its open parts: the player, the transcript with find and downloads, the chapters; closed parts say who can open them. Signed-in people without permission see a restricted recording's title behind a lock; members see all of it, with a link back to the workspace. Restricted and private recordings are "not available" to everyone else. Owners copy the link from the Access dialog. GET /api/v1/public/recordings/{rid} answers with only what the caller may use (docs/api.md).
  • Explore: the public home page and collections (Aviary's home and collection splash pages). /explore shows the featured public recordings, to everyone, and the collections the visitor can browse; a collection's page shows its description and recordings: public ones for everyone, restricted ones behind a lock ("content locked") for signed-in people, all of them for members. The sign-in page links there. GET /api/v1/public/home and GET /api/v1/public/collections/{name}.
  • Explore: search (Aviary's search results page). /explore/search finds recordings by their title, and by what's said in the transcripts the visitor may read, with the matching lines; each line opens the recording at that moment (?t=). Restricted recordings (for signed-in people, locked) and closed transcripts match on the title only, so a search never reveals what they say. A search box sits on the home page and in the header. GET /api/v1/public/search; search() can be limited to a set of recordings.
  • Permission on a recording (Aviary's "registered user with view permission"). Owners give a person with an account permission on one recording from its Access dialog, by email address, and take it away there (audited). They then see all of it on the pages for visitors and in IIIF, whatever its access; private recordings are listed for them, and the home page shows what's shared with them. GET/POST /api/v1/recordings/{rid}/permissions, DELETE /api/v1/recordings/{rid}/permissions/{account}.
    • Fix: IIIF resources of a recording that isn't public now also open with the IIIF access cookie, and the signed links the auth probe hands out work for them (they answered 404, so viewers couldn't play them).
  • Asking for access (Aviary's "request access"). Signed-in people without permission ask a recording's owners for access from its page: to a public recording's closed parts, or to a restricted one, with an optional message; the page shows where their request stands. Owners hear of it by email (when mail is set up), in the recording's Access dialog and on Home under Needs attention, and approve (which gives permission) or decline, audited. POST /api/v1/public/recordings/{rid}/request, GET /api/v1/recordings/{rid}/requests, POST …/requests/{account}/approve|decline, GET /api/v1/access-requests.
  • IP groups (Aviary's "public user with view permission in an IP group"). Owners name address ranges on their namespace's page (a reading room, a campus) that open every recording in the namespace, or the ones chosen in each recording's Access dialog. Visitors from those addresses see what a group opens without signing in: in collections, search, a recording's page ("You're connecting from Reading room") and IIIF, the Authorization Flow's probe included. Groups and choices are audited. /api/v1/namespaces/{name}/ip-groups (list with your address as the server sees it, add, change, delete) and /api/v1/recordings/{rid}/ip-groups (open or close one recording).
    • The visitor's address comes from X-Forwarded-For only when the request arrives from a trusted proxy: the new server.trusted_proxies setting (Settings → Access & embedding; default this machine). A proxy that isn't trusted, or the web app asking on its own behalf, counts for no group. Behind the web app, a reverse proxy that sets the header is needed, or visitors could claim any address (docs/configuration.md, Trusted proxies).
    • Server-side page titles for visitors pass their address on, so a reading room sees the right titles.

Tooling:

  • make openapi, the dev watcher and the pre-commit hook write the same client: the generator reads its input as an absolute path now (given a bare openapi.json, it had baked that name into the client's baseUrl type).

Features

  • settings: move Speakers from the sidebar into Settings (#60)
  • entities: an Entities page in the sidebar (#62)
  • entities: entity setup per namespace or collection, and types of your own (#63)
  • entities: a fixed list of entities, with Unknown and Unlabeled (#64)
  • entities: place names by description with the LLM, and manage entities from chat (#65)
  • entities: a hybrid mode, and the LLM for fixed lists (#66)
  • workflows: if/else, loops, dynamic ports and custom nodes on the canvas (#68)
  • rdf: describe the archive as linked data with Dublin Core (#79)
  • security: add per-namespace data keys and an encrypted file format (#77)
  • rdf: import Dublin Core descriptions into a namespace (#82)
  • metadata: edit every Dublin Core term, and export or import RDF from the app (#83)
  • chat: chat on any page, with the page and highlighted text as context (#61)
  • rdf: query a namespace with read-only SPARQL, from the API, the app and MCP (#84)
  • assistant: add tools, skills, hooks and plugins to the assistant (#81)
  • chat: edit a question you asked and get a new answer (#59)
  • fedora: keep a copy of the archive in a Fedora 6 repository (#88)
  • assistant: draw assistant tools on the canvas (#92)
  • assistant: make and change extensions by asking in chat or by voice (#94)
  • web: add an Extensions page to write, share, switch and try the assistant's extensions (#97)
  • web: draw assistant tools on the canvas (#99)
  • extensions: Python code tools, run in a process of their own (admins only) (#103)
  • notes: pages for notes and every resource, with @ and # links (#118)
  • notes: notes tree in the navigation, note pages and the BlockSuite editor (#122)
  • notes: keep note titles and summaries up to date with the model (#125)
  • voice: send transcription, speakers and speech to cloud providers (#116)
  • notes: the assistant keeps notes, files them in PARA, and notes open on the edgeless canvas (#129)
  • notes: count note refinement and filing in the activity ledger (#133)
  • notes: find, read and write notes over MCP (#137)
  • notes: page history, so any change (the assistant's too) can be undone (#139)
  • brand: Lens mark, favicon and app icons from the v1.0 brand kit (#138)
  • brand: Lens logo in the README and the Cloudron, Synology and QNAP package icons (#141)
  • notes: suggest links to the topics and named things a note mentions (#140)
  • notes: suggest the project or area a top-level note fits under (#142)
  • llm: run a GGUF chat model locally with llama.cpp (#127)
  • storage: keep Lens's own files on this machine or any rclone connection (#148)
  • storage: choose where files go in Settings → Storage and the setup wizard (#149)
  • notes: keep a page's images and attachments where Settings → Storage says (#150)
  • mcp: let admins see and change where files are kept (#151)
  • auth: remember when someone has seen the welcome tour (#152)
  • assistant: give each namespace its own assistant that remembers (#154)
  • web: welcome tour after the first sign-in (#165)
  • sources: route a shared inbox's mail to namespaces with rules (#153)
  • assistant: set up a namespace's assistant in the app and give it chat rooms (#164)
  • web: edit an email watch's routing rules, with a preview (#184)
  • imports: import webhooks with per-namespace tokens (#155)
  • imports: make and revoke import webhooks on the namespace page (#187)
  • documents: keep anytopdf's face and object plugins and ask them about a picture (#221)

Fixes

  • telemetry: trace a worker's first job and keep collector credentials safe (#46)
  • documents: a hung LibreOffice can't block a worker; CI runs on the self-hosted runner (#27)
  • dev: let the API hot reload while the web app is open (#51)
  • docker: keep the host's node_modules, .next and .env out of the web app image (#55)
  • transcribe: fall back when an engine won't import instead of failing the job (#58)
  • ci: run cleanly on the self-hosted runner (#112)
  • chat: stop the recording chat's scroll effect from returning a Promise (#106)
  • notes: keep typing when a thing's page is first saved, and open only web links (#124)
  • mcp: type graph_related's relation so the type check passes, and format test_mcp (#131)
  • db: reconnect SurrealDB connections that drop (#123)
  • notes: narrow read_note's draft page so mypy passes (#143)
  • graph: let the graph explorer and canvas zoom in further (#144)
  • search: satisfy mypy on the text index sync at startup (#178)
  • docker: survive slow networks while installing Python packages (#182)
  • iiif: refuse password sign-in on the access page when passwords are off (#158)
  • auth: make only one first admin when setup requests race (#159)
  • iiif: give access tokens only to viewers the person confirmed (#160)
  • mcp: cap the page offset of list_recordings, list_entities and list_topics (#200)
  • notes: don't fetch web images in Markdown the assistant writes (#194)
  • media: let ffmpeg read only audio and video containers (#196)
  • iiif: keep each namespace's copy of imported IIIF media apart (#201)
  • extensions: ask before running another person's tool (#203)
  • auth: refuse sign-in destinations with control characters (#197)
  • auth: don't revive a session that ended during a refresh (#198)
  • docker: publish the dev sensor ports on this machine only (#204)
  • telemetry: drop saved headers when the endpoint moves, even with the mask (#199)
  • ingest: stop ffmpeg, antiword and pdftotext runs that never finish (#202)

Performance

  • activity: write request rows off the event loop (#132)
  • ci: make the test suites fit the self-hosted runner (#145)
  • docker: fetch speech and object models at run time, not at build time (#183)

Dependencies

  • Bump next from 16.0.8 to 16.3.6 in /nextjs-frontend (#3)
  • bump the minor and patch dependency updates together (#39)
  • bump zod from 3.23.8 to 4.6.5 in /nextjs-frontend (#35)

0.3.0 September 30, 2026

The web app implements the Lens Archive design (built on the Aladdin design system).

  • Foundations: design tokens with a dark theme and speaker and emotion palettes, self-hosted fonts (DM Sans, Source Serif 4 for transcripts, JetBrains Mono), and the design's components: buttons that explain why they're disabled, status, role, speaker, emotion and job-step chips, secret fields, tables with sorting and paging, dialogs, drawers, toasts, banners, the four-colour step loop and verdict cards.
  • App shell: collapsible navigation, namespace switcher, ⌘K palette, live activity drawer, account menu with appearance and keyboard shortcuts.
  • Screens: Home, Library, Import, Reports; the audio and video recording pages with the player; Search; Chat with citations, tool steps and approvals; Speakers; Graph; Batch runs and Collections; Activity; Sources; Pipelines and Templates; Sharing and Embed; Settings; Admin (people, members, audit, health); IIIF metadata and publishing; sign-in, first-run setup, password reset, API tokens, and signed-out-mid-task recovery.
  • Every screen follows the role rule (actions you can't take are disabled with a reason; namespaces you have no role in never appear), has loading, empty and error states, and works in dark mode and at phone width.
  • The web app forwards API, media, embed, IIIF and report paths at request time, so one build works against any API address.
  • Features the design shows but the API doesn't support yet are disabled with a reason and listed in docs/backend-gaps.md.

Backend:

  • Fix: on the embedded engine, search silently missed most segments after a restart (the full-text index lost entries on reopen). The index is now repaired once per process before the first search.
  • Fix: IIIF "Open in" viewer links can be saved in Settings (only http(s) URLs are accepted).
  • The job event stream opens immediately.

Tooling:

  • CI reports coverage in each run's summary and keeps the reports as artifacts (Coveralls needs a paid plan for private repositories). The pnpm version comes from the frontend's package.json.
  • pre-commit installs only the dev tools (the processing extras include macOS-only packages), and every hook passes: the backend is formatted with Ruff throughout, the frontend with Prettier at 120 columns.

0.2.0 September 30, 2026

Lens moves from a single-process prototype to a platform on the Next.js FastAPI template.

  • Backend
    • The prototype's processing engine now lives in app/domain, unchanged in behaviour.
    • The API is split into routers under /api/v1 with Pydantic request and response models, so the OpenAPI schema and the frontend's typed client cover every endpoint.
    • Authentication: short-lived JWT access tokens and rotating refresh tokens (with reuse detection) for NextAuth, replacing session cookies and CSRF tokens; password reset by email; API tokens unchanged.
    • Media is served through signed links, since <audio> and <img> can't send bearer tokens.
    • SurrealDB: a connection pool for servers, and automatic retries of write conflicts.
    • Workers run as their own process (lens worker); the API can still run them inline for development.
    • Removed the template's Postgres, SQLAlchemy, Alembic, fastapi-users and Vercel backend deployment.
  • Frontend
    • NextAuth (Auth.js v5) with a credentials provider backed by the API, token refresh, first-run setup and password reset. Public registration is gone: admins invite people.
    • A typed API client wired to the session, and rewrites so media and IIIF are served from the web app's origin.
  • Operations
    • Docker Compose for development (SurrealDB, API, worker, web app, MailHog) and a production-shaped compose file.
    • CI runs lint, type checks, an OpenAPI drift check and the tests against embedded SurrealDB and a SurrealDB server.