Automations¶
Automations let Lens act when something happens. Lens follows what happens in every source (an email arrives, a file is added, a sensor raises an alert, someone writes to Lens in chat) as a stream of time-coded activities. An automation watches that stream and the clock, tests its conditions, and acts: on its own for routine things, through the agent, or by asking you first. The timeline is the same stream, filtered and reviewed.
The workflow studio is where you make them: Automations, under Automate in the nav. Describe what you want in words, start from a starter, or build it yourself in When, If and Then; test it on something that happened, then save it and turn it on. Agents do the same through the API, MCP and the assistant.
Status: in progress. The studio is built in modules, and only what is merged is called built: see Built, in progress, planned.
In this guide:
- Five minutes: tell me when the school emails: a first automation, from the starter to its run on your timeline.
- The studio: the list, the When, If and Then builder, and an automation's page.
- Triggers and kinds: everything that happens is an activity of a kind (an email received, a sensor alert, a chat message), and a trigger listens for kinds, a schedule, a time, a webhook or a click.
- Joins: triggers that wait for each other: all of them, a number of times, one that doesn't come in time, or a batch.
- Conditions: match trees for a trigger's Only when and an automation's If, their operators, looking back over what happened, and the decide node for a judgement call.
- Actions and risk: what an automation can do, and which actions run on their own and which ask first.
- Approvals: what waits for your pick, on the Approvals page or with a yes in your chat app.
- Time: schedules, set times, times relative to an activity, and actions that wait.
- Variables and templates: filling actions in from what happened, and values kept between runs.
- Limits and costs: how often an automation may run, and what it spends.
- The API and MCP tools: all of it for agents.
- Built, in progress, planned, and what to refine later.
How Lens reads the stream underneath (sources, following it, timeline reads) is at the end of the Contract, after the actions; saving and running automations come after variables and templates.
Five minutes: tell me when the school emails¶
How do I make Lens tell me when the school emails? Start from the Tell me about school emails starter, change “school” to the school's address, press Test, then Save and turn on.
You need two things first: the mailbox the school writes to, read by Lens (an email source, IMAP or Gmail), and a chat app you talk to Lens in (Telegram or Slack), where it tells you.
- Open Automations › New automation. The cursor is in Describe it in words.
- Pick the starter Tell me about school emails under it. Or say it in words ("when the school emails me, tell
me") and press Enter: the model fills the builder in and says what it left out. The starter's When is an email
received from an address with “school” in it; its Then tells you in your chat app, then posts the subject to the
familyMatterbridge room. - Change “school” to the school's address. In When, the Only when row tests the sender: write the school's
address (
office@stmarys.org) or the end of it (@stmarys.org). No family room? Remove that action. - Press Test. It runs the draft on a recent email, or on a sample one, and lists what each action would do: tell you at once, and ask you first before posting to the family room. Nothing is sent.
- Press Save and turn on. From now on each email from the school sets it off; emails that came before aren't replayed.
When the school's next email arrives:
- You are told in your chat app: “School email: Trip on Friday”.
- Posting to the family room reaches other people, so it waits for your pick on the Approvals page
(
/approvals; a badge in the top bar counts what waits), and your chat app gets a message with a link to it. Approve, edit or decline it there. A yes in your chat app decides only actions that stay inside Lens, so for a post to a room the reply points you to the page. - The run shows on the Timeline (filter it by the automation to see what it did and caused; the email, opened, lists the run and the waiting post under What this caused, with Approve and Decline right there) and on the automation's Runs tab, with what each action did and what it cost.
Everything else in this guide builds on this one: more triggers, sharper conditions, other actions, and time.
The studio¶
Automations (under Automate in the nav) lists every automation you can see. Schedules, everything that runs on a clock, is its other tab, so both share one row in the nav; for admins, Routines and New routine are at the top of both. Each row reads its triggers in words (“Email received”, where the subject contains “trip”), and shows how its last run went, its runs today, the actions waiting for your pick (a link to Approvals), what it cost this month and when it runs next. The switch turns it on or off. Its menu has Run now, Duplicate (a copy, switched off) and Delete, which you undo from its toast rather than confirm first.
New automation opens the builder, with the cursor in Describe it in words: type a sentence ("when an email about a school trip arrives, tell me") and press Enter, and the model fills the builder in, saying what it left out. Or pick one of the starters under it, which fills the builder in and says what to change first. Or build it yourself:
- When: one card per trigger. An activity kind has Only when rows (a field, a test that suits its type and a value); a schedule has presets or a cron line, its time zone and its next few times; a time is a date and time; a webhook's address shows once you save. With two or more triggers, the join line says whether any one of them starts it or it waits for all of them.
- If: rows tested on what happened, all or any of them, with the fields of the variable catalog to pick from.
- Then: one card per action, in order. Each card's settings come from the action's params; typing
{{in a text field opens the variable picker (arrow keys, Enter or Tab to put one in, Esc to close). A badge says whether the action runs on its own or asks you first, Wait first delays it, and once the automation is saved Let it run on its own stops an outside action asking (POST /api/v1/automations/{id}/policy).
Enter in a row's last field adds a row. Test runs the draft on a recent activity, or on a kind's sample, and lists what each action would do; nothing is changed. Save and turn on saves it switched on, Save keeps it as it is (a new one stays off), and Ctrl or Cmd+Enter saves from anywhere. The API's problems show next to the trigger, row or action they are about.
An automation whose workflow branches (a Decide node, or an action after a condition) can't be read as a list: the builder says so and opens it in the canvas, where When and If still edit.
An automation's page¶
An automation's page has its switch, Run now and five tabs (?tab= in the address opens one):
- Edit: the builder.
- Runs: newest first, 20 at a time with Older runs for the next page. Each reads how it went, what started it, what its actions did and what it cost; opening one shows each action's outcome and the run's log. A run held over a budget offers Run it once or Skip. Run now opens this tab.
- Coming up: its next schedule times,
attimes and actions that wait, soonest first. - Variables: what it keeps between runs (Saved variables). Edit a value as text or JSON and press Enter; Clear is undone from its toast; a new name and value are added with Enter.
- Costs: its budget and its activity: the calls it made and what they cost.
Contract¶
Every part of the studio builds on the shapes below. A change to one of them comes in a small pull request of its own that changes the code, this section and the tests together.
They come in the order you meet them when you build one: what happens (activities and their kinds), triggers and joins, conditions (match trees), what a run sees (run context), and what it does (the event workflow scope, actions and intents). The last three parts are how Lens reads the stream underneath, for the timeline and the automations that follow it.
Activities¶
Built (app/domain/events.py). Everything that happens in Lens, from every source, is an activity: a plain dict in
the shape of a W3C Activity Streams 2.0 activity, with a Lens kind
that triggers subscribe to. events.check_activity(a) checks it, naming the first problem.
{"id": "create-recording-7", "type": "Create", "kind": "asset.added", "published": "2026-10-15T08:12:03.000Z",
"actor": {"type": "Person", "id": "lens:account/1", "name": "Sam"},
"object": {"type": "Audio", "id": "lens:recording/7", "name": "Call with the school", "url": "/resources/7",
"media": "audio"},
"target": null, "tag": [], "summary": "Sam added “Call with the school”", "space": 1,
"lens": {"source": "recordings", "pos": ["2026-10-15T08:12:03+00:00", 7], "untrusted": ["object.name"],
"cause": null}}
id: stable, made from what the source reads (create-recording-7,log-12), so an activity is the same every time it is read: 1 to 120 lowercase letters, digits and_ . : -.type: the AS2 verb (Create, Update, Delete, Add, Remove, Move, Join, Leave, Arrive, Announce, Like, Accept, Reject), which the kind decides.published: when it happened, ISO 8601 in UTC with milliseconds, so times from every source sort as text.actor: who did it,{type, id, name}. Sources and storage areService, peoplePerson, Lens itself, the agent and automationsApplication(lens:automation/4; Lens islens:app). Entities are never actors; they go intag.object: what it is about, with the kind's own fields. Ids arelens:<table>/<id>references, so actions can act on them;events.ref_id(ref, "recording")reads the id back.target: where to, when the verb has one (Add to a collection), else null.tag: up to 50 objects such as{"type": "Mention", "id": "lens:entity/9", "name": "St Mary's", "entityType": "ORG"}: an entity's type is its code, so a trigger can fire on any person mentioned (mentions).summary: one line of at most 300 characters, made by fixed rules, never by a model.space: the namespace id, or null for activities of no namespace.lens: Lens's own fields.sourceandpos(the source's position,[time, id]) are for the stream machinery.untrustedlists the dotted paths holding outside text (titles, email bodies, webhook payloads): prompts wrap them withuntrusted.wrap.causeis{automation, run, depth}when an automation's action caused it.spaceslists every namespace when there are several.audiencenarrows who may see it:space(the default: people who read one of its namespaces),owner(only the account inlens.owner) oradmins.
Kinds¶
Built. events.kind(name, *, label, source, as_type, object_type, parent=None, fields=None, untrusted=(),
sample=None, noisy=False) registers a kind: a name like noun.past_verb, a label for people, the source that makes
it, its AS2 verb and object type, a parent (a trigger on the parent matches it too), JSON Schemas for its object
(and actor or target) in fields, the paths that hold outside text, a realistic sample (checked when it is
registered), and whether it is noisy (left out of the timeline unless named). events.kinds() lists them,
events.kind_info(name) gives one with its children and its whole schema (every activity's fields, its parents',
then its own), events.descendants(name) the kind and every kind under it, and events.field_at(kind, path) the
schema of one field, whose title names it in words (match.describe(tree, kind) says "the title contains “call”").
Every kind's object has type, id, name and url; the table lists what each adds. Its fields' titles, a sample
and the paths holding outside text are in GET /api/v1/activities/kinds (event_kinds over MCP).
| Kind | Parent | Source | Object | Example summary |
|---|---|---|---|---|
asset.added |
recordings |
{type: Audio, Video, Image or Document, id: lens:recording/<id>, name: its title, url: /resources/<id>, media: audio, video, image, document or transcript, content_type} |
Lens added “Scan 12” | |
email.received |
asset.added |
recordings |
from, to, cc (addresses), subject, list, mailbox, date (sent), snippet (the start of its text), attachments (how many) |
Work mail received “School trip on Friday” from office@stmarys.org |
calendar_event.added |
asset.added |
recordings |
startTime, endTime, location, organizer, attendees (addresses), calendar |
Family calendar added the event “Parents' evening” on 2026-10-20 |
feed_post.received |
asset.added |
recordings |
href (the post's address), site, feed (its title), author, published, categories |
Harbour news received the post “Autumn regatta” from harbour.example.org |
file.added |
asset.added |
recordings |
path (in its source), mediaType, size (bytes), duration (seconds) |
Office share added “Minutes” |
file.changed |
asset.added |
recordings |
as file.added, and previous (id, url: the asset it was before); an Update, for a file changed in its source and read again |
Office share updated “Minutes” |
upload.added |
asset.added |
recordings |
path (the file's name), mediaType, size, duration |
Sam uploaded “Call with the school” |
web_page.saved |
asset.added |
recordings |
href (the page's address), site |
Sam saved “Museum opening hours” from museum.example.org |
import.received |
asset.added |
recordings |
via: hook (an import hook), paste (pasted text) or file (a file read as text) |
Phone shortcut imported “Shopping list” |
asset.processed |
jobs |
the asset's fields, job (lens:job/<id>), steps, batch; tagged with its entities |
“School trip on Friday” finished processing (pages, analyze, embed) | |
asset.failed |
jobs |
as asset.processed, with step (where it failed) and error |
Processing “Call with the school” failed at transcribe | |
asset.deleted |
log |
type, id, name, media |
Sam deleted “Old minutes” | |
asset.moved |
log |
type, id, name, url, media, from and to (the namespaces), collection (where it is now); a Move |
Sam moved “Old minutes” to work | |
calendar_event.changed |
log |
startTime, endTime, location, calendar, and before (startTime, endTime) |
Family calendar moved “Parents' evening” to 2026-10-21 18:00 | |
comment.added |
comments |
a Note: content, inReplyTo (the comment it answers), quote (the passage), t0, t1 (the moment, ms); target is the asset, or the activity of the timeline it was written on (lens:activity/<id>) |
Sam commented on “Call with the school” | |
reaction.added |
log |
the activity reacted to (an Activity, lens:activity/<id>, name its summary), emoji, activity (its id); noisy |
Sam reacted 👍 to “Work Gmail received “Parents' evening”” | |
note.created |
note_pages |
a Page: summary, about (what it's the page of), place, author (person or assistant) |
Assistant wrote the note “St Mary's School” | |
note.saved |
note_versions |
as note.created, with version (the page as it was before) and why |
Sam edited the note “School trip plans” | |
clock.tick |
clock (never stored) |
an Event: at (the moment, UTC), cron, timezone, local (the time there, in words), description (the schedule in words, or once), late; actor Lens clock |
Lens clock: Fri 16 Oct at 07:00 (every weekday at 07:00, Europe/London) | |
entity.changed |
graph |
op (created, renamed, merged, split, linked, unlinked, deleted, rolled back, changed), change (the graph's own op), entityType, before (a renamed entity's name), version, count; tagged with its entities |
Sam renamed “St Marys” to “St Mary's School” | |
sensor.reading |
sensor_readings |
{type: Note, id: lens:sensor_reading/<id>, name: "<stream>: <what it said>", url: /sensors/<id>}, stream, value, text, fields (a JSON reading's numbers), level (syslog), pattern, label (the line's pattern and its label); noisy |
Kitchen sensor temperature: 31.2 | |
sensor.alert |
log |
{type: Note, id: lens:sensor_pattern/<id>}, label (alert or notable), pattern, example (a line it fits), count (lines in all), lines (in the batch that raised it), hour |
192.168.1.1 logged an alert: wlan |
|
sensor.joined |
sensors |
{type: Service, id: lens:sensor/<id>, url: /sensors/<id>}, sensor_type (mqtt, syslog, ...), device, status |
A new MQTT device reported: zigbee2mqtt/kitchen | |
sensor.changed |
log |
{type: Service, id: lens:sensor/<id>, name, url: /sensors/<id>}, status (active, paused, ignored, new) and before (what it was); an Update by the person who switched it |
Sam paused Kitchen sensor | |
chat.message |
chat_messages |
{type: Note, id: lens:chat_message/<id>, name: the start of it, url: /chat/<id>}, text, channel (web, telegram, slack, bridge, mailbox), room, chat, direct (a private chat or the web app), attachments (how many files) |
sam wrote in Telegram: “groceries: milk, eggs” | |
routine.ran |
routine_runs |
{type: Note, id: lens:routine_run/<id>, url: /routines/<id>}, status (done, partly, error, skipped), trigger (schedule, manual), startTime, endTime, actions, failed, error, changes (graph changes applied and proposed), cost_usd, tokens |
Nightly tidy ran | |
budget.warned |
log |
{type: Note, id: lens:budget/<resource>, url: /costs}, resource (all, space:state (near, over), share, usd, tokens, estimate, cap_usd, cap_tokens, period, since |
The pods namespace has used 82% of its budget | |
backup.finished |
log |
{type: Document, id: lens:backup/<name>, url: /settings/backups}, label (manual, scheduled), ok, error, kind (export, folder), bytes, files, copied (to the storage connection) |
A scheduled backup finished: scheduled-20261015-020000.surql.gz | |
lens.release |
log |
{type: Note, id: lens:release/<tag>, url: its release notes}, tag, title, current (what this server runs), published_at |
Lens v0.7.0 is out (this server runs 0.6.0) | |
webhook.received |
log |
{type: Note, hook: the webhook's id, payload: what was sent (JSON, a form's fields or text), content_type, query}; the actor is the webhook (Service, lens:automation_hook/<id>, "Webhook for |
Webhook for Front door was called | |
approval.waiting |
approvals |
{type: Question, id: lens:approval/<id>, name: what it would do, url: /approvals?id=<id>}, status, action, effect, automation, automation_name, run, node, about (the activity that caused it), expires_at; its person alone sees it |
School emails asks for your pick: Post in the Matterbridge gateway family: “Trip on Friday, bring a packed lunch” | |
approval.decided |
approval_decisions |
as approval.waiting, with status (done, failed, declined, expired) and error; Accept when approved, Reject when declined or expired |
Sam approved: Post in the Matterbridge gateway family: “Trip on Friday, bring a packed lunch” | |
share.sent |
shares_sent |
what was shared with a circle as it read then (type, id, name, url, summary: the timeline's line, media, kind and about: the activity shared, published), content (the note), share, circles, people, with_item; tagged with its entities; the sharer alone sees it |
Adeel shared “School trip on Friday” with Immediate family | |
share.received |
shares_received, shares_remote |
as share.sent, without circles and people; url only when the person can open it; in no namespace; that person alone sees it. From someone on another server (shares_remote): a Note with name, content and remote: true, the actor's handle, all outside text |
Adeel shared “School trip on Friday” with you | |
agent.asked |
agent_asks |
what someone's agent asked of the person (what: free_busy, the window as startTime and endTime; or question, the question as name and the answer as content, both outside text); in no namespace; the person asked alone sees it |
Sam's agent asked when you're free, Mon 20 Oct – Fri 24 Oct |
A new asset is one activity, of the most specific kind its row tells, decided by its content type and where it came
from, never by reading its file: an email (content type email, or from an IMAP or Gmail mailbox), a calendar event
(content type calendar_event, or from an iCal feed or Google Calendar), an import hook's, one saved from the web
(webcapture.py, mediaurl.py), a file from a storage source or Drive, an upload, an import (pasted text, a transcript
imported), else asset.added. A file an email or archive held, made a resource of its own, stays asset.added: the
email is what arrived. A trigger on asset.added matches every one of them.
A run of an asset's pipeline that finishes is asset.processed (it succeeded) or asset.failed, one activity per
run, batch runs included. Its actor is whoever queued it, and its tags are the asset's entities (the fifty most
mentioned, as many as an activity holds, each with its entityType), which exist only once it has been processed: asset.processed is the kind for "when an email about St
Mary's is understood". A run tried again finishes again, as another activity. A deletion is logged as
asset.deleted where the asset is deleted (once per asset), and a move to another namespace as asset.moved, a
Move by whoever moved it, seen in the namespace it is in now (a trigger there on asset.moved is "when something
arrives from another namespace"); a calendar event read again that starts at another time is logged as
calendar_event.changed (once per event and new start), with the times it had before.
A comment on an asset, or a reply on its thread, is comment.added, by whoever wrote it, with the asset as its
target; a comment written on an activity of the timeline has that activity as its target
(lens:activity/<id>), and an emoji given to one is reaction.added, with the activity as its object
(Timeline). A note page is note.created when it is made (the assistant's own notes too) and note.saved when it
changes: at most one activity per page in each ten minutes of the clock (note-<page>-<window>), read from the
versions the notebook keeps of each change. A follower hears of a window's first change, as soon as it is made; the
timeline shows its newest. Every change to the entity graph (graph_event, graph history) is one
entity.changed, about its first entity that is still there, tagged with up to twenty of its entities and in the
first of its namespaces. Its op says what happened in a word; its actor is the automation, the assistant or the
agent over MCP it was made through, else the person, the routine, or Lens (analysis). A change that spans namespaces
(a link between two) names entities of each, so only admins see it, with every namespace in lens.spaces.
Fields come from what the row keeps, so reading them costs nothing on the ingest path. A message's addressing is
kept as it is listed (recording.mail: who sent it to whom, its subject, list, mailbox, and Gmail's snippet), an
event's times, place and people as it is imported (recording.event), and an email's attachments and the start of
its text once it has been read (recording.email). Emails and events imported before these were kept have only the
fields their rows had. Outside text (titles, subjects, addresses, snippets, places, file names, page addresses) is
listed in lens.untrusted.
Sensors as activities¶
Built (app/domain/events_streams.py). A sensor's actor is the sensor (Service, lens:sensor/<id>, its name); a
sensor with no namespace makes activities for admins, as its page is. Its names, streams, text and patterns are
outside text.
sensor.readingis a reading the sensor keeps, as its handling stores them:all,changes(a change, and once an hour while a value stays the same), none forsummaryornone, and none while it is paused or ignored. To trigger on every change, set the sensor to store changes. It is noisy: the timeline leaves it out unless asked for. Readings are read one sensor at a time over the index on(sensor, at), and a follower whose hint names sensors ({"actors": ["lens:sensor/5"]}, which an automation's trigger on one sensor gives) reads only theirs, so a busy sensor nobody listens to is never read.sensor.alertis a log pattern (Sensors) labelled alert or notable that new lines fit, raised by the intake as it writes them: once per pattern per hour (the event log's key is the pattern and the hour, so two processes raise it once), whether the lines are kept or dropped. A label the decision model wasn't sure of raises none until a person settles it, and a paused or ignored sensor raises none.sensor.joinedis a device or host that made itself a sensor by reporting for the first time (MQTT, syslog), read from itsstorage_sourcerow bycreated_at; a sensor added by hand isn't news.sensor.changedis a person switching a sensor: paused, ignored, resumed (active) or set back to new, logged when the change is saved, with the status it had before; saving the same status again, or a rename, logs nothing. It is seen in the sensor's namespace, or by admins when it has none, so "when the kitchen sensor is paused, tell me" is a trigger on it withobject.statusequals paused.
Chat as activities¶
Built (app/domain/events_streams.py). chat.message is what a person said to Lens: a question in the web app (Chat,
the setup conversation) or a message to Lens in a chat app (Telegram, Slack, Matterbridge, the assistant's mailbox).
It reads the messages Lens already keeps (chat_message with role user), so nothing new is stored, and what isn't
said to Lens in a room is never kept, so it is never an activity. Its text and names are outside text.
- In the web app the actor is the person (
lens:account/<id>), and the activity is theirs alone (lens.audienceowner): not even admins or others in the namespace see it. Its namespace is the conversation's scope. - In a chat app the actor is who wrote (
Person,lens:chat_user/<channel>:<their id there>, their name there), since a chat user isn't a Lens account. A room given to a namespace's assistant belongs to that namespace; any other conversation (a private chat, an open room) is the answering account's alone. The conversation keeps where it is (chat.via: the channel, the room, who, a private chat or not) from its next message on. - A question an automation's
ask_agentasks (originon the message) isn't a person's, so it is never achat.message, and an automation can't set itself off that way.
Lens itself as activities¶
Built (app/domain/events_streams.py). What Lens does on its own is for admins, unless it is about one namespace.
Each is logged once, however many processes see it happen.
routine.ranis a routine run that ended: done, partly (some actions failed), error, or skipped over budget once someone picked to skip it. Its actor is the routine (Application,lens:routine/<id>). A run held over budget is one when it ends; one someone let run anyway runs again as a new run, and that run is the activity. A routine of one namespace makes activities in it.budget.warnedis a budget crossing its warning share (warn_at) or its cap, once per period and state, logged by the same check that alerts on it (Budgets). A namespace's budget makes it in that namespace.backup.finishedis a backup taken in Settings or through the API, or on the schedule, which says whether it worked (a scheduled one that failed says why) and whether it was copied to the storage connection.lens.releaseis a newer Lens release, once per release, when Lens tells the admins and notification targets (Updates).
Approvals as activities¶
Built (app/domain/events_approvals.py). An automation's action that asks first (Approvals) is
approval.waiting when it starts to wait, read from the approval by created_at, and approval.decided when it is
approved (done or failed), declined or expires, read by decided_at. Both are the person's own (lens.audience
owner): the timeline shows them to that person alone, not even to admins, and the person's automations can react to
them ("when an approval has waited a day, remind me"). The actor of approval.waiting is the automation (the
assistant for its own approvals); of approval.decided, the person who decided, or Lens when it expired. An
automation's approval carries its run as lens.cause, so a follower reacting to one can't loop for ever. What it would
do (object.name) and why it failed hold text the run read from outside Lens, and are listed in lens.untrusted.
Automations: triggers and joins¶
Built as shapes with checks (app/domain/automation_spec.py), saved (Saving automations) and
run (Running automations), joins included. Each check gives a cleaned copy, or a ValueError
naming the first problem.
{"name": "School emails", "enabled": true, "spaces": [3], "run_as": 1,
"triggers": [{"as": "mail", "on": "event", "kinds": ["email.received"],
"where": {"all": [{"path": "object.from.0", "op": "contains", "value": "@stmarys.org"}]}},
{"as": "morning", "on": "schedule", "cron": "0 7 * * 1-5", "timezone": "Europe/London"}],
"join": {"mode": "any"},
"if": {"all": [{"path": "events.mail.object.subject", "op": "contains", "value": "trip"}]},
"workflow": 12, "policy": {"n7": "ask"}}
check_trigger(t),check_triggers(list): one to ten triggers, each named once inas(lowercase letters, digits and_).onisevent(kinds, an optionalwherematch tree,from_automations, false by default so activities made by automations are left out),schedule(cron, five fields as routines take them, andtimezone),at(one moment, intimezone),relative(a timefieldof a matching activity plus anoffset, which may point back:-30m),webhook(hook, given when the automation is saved) ormanual.check_join(j, aliases): how the triggers make a run.any(the default): each on its own.all: every triggerwithina time, optionally on the samekeypath.count:at_leastn of triggerofwithin.absent: after triggerafter, triggermissingnot seenwithin.batch: collect what matcheswithin, then one run.check_duration(text, back=False): seconds in30s,30m,2hor7d(at most a year); a minus only where an offset may point back.check_if(tree): the If is a match tree over the run context, so its paths start with one of the context's keys.check_policy(p, nodes, allow_auto=False):{action node id: ask or auto}. Only a person in a signed-in session lets a node run on its own; everywhere else (agents, drafts, the workflow API)autois refused.
Joins¶
Built (match.join_step). Running automations hands each activity a trigger matched (after its guards and its
claim) to match.join_step(db, cfg, automation, alias, activity), which gives a list of completions, each
{"as": alias, "events": {alias: activity or [activities]}, "event": the last}, and each completion fires the
automation: its If and graph see every trigger's activity in events ({{ events.mail.object.subject }}), and
event is the last. Triggers a join doesn't name fire on their own, as with any. Windows are measured between the
activities' published times.
| Mode | Example | Completes |
|---|---|---|
any |
{"mode": "any"} |
at once, for each activity |
all |
{"mode": "all", "within": "1d", "key": "object.from.0"}: an email from the school and a calendar event from the same sender within a day |
when every trigger has an activity within within of the others (on the same key); each trigger's latest one goes in events, then it starts again |
count |
{"mode": "count", "of": "login", "at_least": 3, "within": "5m", "key": "actor.id"}: three failed logins from one device in five minutes |
when of has at_least activities within within (per key), with the list of them; then it counts again |
absent |
{"mode": "absent", "after": "mail", "missing": "reply", "within": "2d", "key": "object.thread"}: no reply within two days |
when within passes after after with no missing on the same key published after it, with the first after activity; a missing one in time cancels it |
batch |
{"mode": "batch", "of": "alert", "within": "1h"}: the hour's alerts, acted on once |
within after the first activity, with every one collected (up to 500), by trigger |
- What a join waits on is kept in
automation_state(kindjoin) with small copies of the activities (long text cut), written so two processes at once lose nothing (a version on each row). A row expireswithinafter its newest activity, or a day after anabsentorbatchtimer's time. absentandbatchset timers for their automation (absent-<automation>-<key>,batch-<automation>), so they outlast a restart and fire once; their handlers,join.absentandjoin.batch(match.TIMER_HANDLERS), fire the automation. Amissingactivity in time calls its timer off. A timer that ends without firing (the automation is switched off, or Lens was off pastautomations.catch_up) drops what it waited on, so the next activity starts afresh; one that can't be set (the automation has 1000 timers waiting) drops it too, with a line in the log.match.describe_trigger(t)reads a trigger in words (“Email received”, where the subject contains “trip”; every weekday at 07:00 (Europe/London); a call to its webhook), andmatch.describe_triggers(triggers, join)an automation's triggers, then its join when there are several.match.describe_join(join)reads a join in words:all of the triggers within 10 minutes, for the same object.from;3 times login within 5 minutes;mail, then no reply within 2 days.
Webhook trigger¶
Built (app/domain/automation_hooks.py, POST /api/v1/automations/hooks/{token}). A trigger {"on": "webhook"}
is a secret address anything that can send HTTP calls to set the automation off: Home Assistant's rest_command, a
phone shortcut, another service. No webhook exists until an automation with such a trigger is saved.
automation_hooks.token_for(db, cfg, automation_id, alias, signed=False)makes the hook for one trigger when the automation is saved:{"hook": id, "url": address, "path": address without the server, "secret": signing secret}. The address and secret are shown this once; the row (automation_hook {automation, alias, token_hash, secret, signed, created_at, last_called_at, calls}) keeps only the token's hash and the secret sealed with the server's key. Calling it again for the same trigger replaces the hook, and the old address stops working. The trigger keeps the hook's id inhook.automation_hooks.revoke(db, hook)deletes it (when the trigger or the automation goes);for_automation(db, id)lists an automation's hooks.- A call needs only the token in the address (
lah_...). It takes JSON, a form (text fields; files are refused) or text, up to 64 KB, at most 60 calls a minute per hook (in the API process; 429 withRetry-Afterpast it), and answers 202{"id": activity}at once. 401 for a token that isn't a hook's, 403 while the automation is switched off, 413 over 64 KB, 415 for anything but JSON, a form or text. - What was sent becomes a
webhook.receivedactivity in the event log, with the payload, its content type and the query as outside text. Its namespace is the automation's (admins see it while the automation isn't stored). - Signed calls: a caller that signs the Standard Webhooks way (
webhook-id,webhook-timestampin seconds,webhook-signature: v1,<base64 HMAC-SHA256 of "<id>.<timestamp>.<body>">keyed with the secret, as Lens signs its own notification webhooks) has the signature checked, and the time must be within five minutes. A hook made withsigned=Truerefuses calls that aren't signed. A call naming its message (webhook-id, or anIdempotency-Keyheader) is logged once however often the sender retries it. - In Docker the API runs no background work, so the worker's automations see the call on their next look (5
seconds,
events.poll_seconds).
Lens has three kinds of webhook address; they don't share tokens or do each other's jobs:
| Address | Made by | What a call does |
|---|---|---|
POST /api/v1/automations/hooks/{token} |
saving an automation with a webhook trigger | sets that automation off with what was sent (webhook.received); nothing is kept but the activity |
POST /api/v1/sensors/push/{token} (Sensors) |
an admin adding a webhook sensor | keeps readings (numbers, on/off, text) for charts, with the sensor's handling |
POST /api/v1/hooks/import/{token} (Import webhooks) |
a namespace's owner | imports files, web pages or text as assets |
Match trees¶
Built (app/domain/match.py). One condition language for a trigger's where, an automation's if, and the
condition, filter and switch nodes of a workflow:
M := {"all": [M, …]} | {"any": [M, …]} | {"not": M} | {"path": "a.b.0", "op": OP, "value": V}
- A test (
{path, op, value}) reads the part of the value at a dotted path, list items by number (object.from.0); no path tests the value itself. The tests are in the table below. allholds when every condition in it does (an emptyallalways holds),anywhen one does (it needs at least one),notwhen its condition doesn't.- Values are literals, never templates, so matching stays cheap and safe.
- A tree nests at most 6 deep and holds at most 50 tests. A problem is named with where it is:
the condition, all.1.not: compare with a number. match.describe(tree, kind=None)reads a tree in words:object.from ends with “@stmarys.org” and object.subject matches “trip” or “outing”. With a kind, fields are named by the titles in its schema.
Operators¶
The first seven are the workflow engine's tests and work exactly as a condition node's always have. The others are
built in app/domain/match.py (match.OPS lists them all). When the value tested is a list, those pass when one of
its items does (as contains does), and not_in passes when none does. A value of the wrong kind, such as text for
between on numbers, doesn't pass; a test's own value is checked when it is saved, with the problem in words.
| Test | Value | Passes when the value tested |
|---|---|---|
exists, empty |
none | is set; is missing, empty text, [] or {} |
equals, not_equals |
anything | is (or isn't) exactly the value |
contains |
anything | is text holding it in any case, or a list or object holding it |
gt, lt |
a number | is a number more (less) than it |
in, not_in |
a list of up to 500 values | is (or isn't) one of them; text in any case |
starts_with, ends_with |
text (a number is read as text) | is text, or a number, starting (ending) with it, in any case |
matches |
a pattern of up to 200 characters, such as trip or ^re: |
is text holding a match, in any case |
between |
two numbers, or two times: [1, 5], ["-1d", "now"] |
is a number, or a time, from the first to the second (both included) |
before, after |
a time: 2026-10-16T07:30, now, or from now: 2h, -30m |
is a time before (after) it |
within |
a duration: 30m, 2h, 1d |
is a time no further than that from now, either way |
weekday |
days: ["sat", "sun"], ["monday"] or ISO numbers (1 is Monday) |
is a time on one of those days |
time_between |
two times of day: ["22:00", "06:00"] |
is a time of day from the first up to the second, across midnight when the second is earlier |
changed |
none, or a path to keep values apart by: actor.id |
differs from the value this test saw last time (the first one seen is no change) |
mentions |
the entity: {"type": "PERSON"}, {"name": "Acme*"}, both, or {"id": "lens:entity/9"} |
is an activity (its tag), or a list of tags, naming an entity of that type (any of a list), with a name like that, or with that id |
- Times are ISO 8601 times or dates. A time written without a zone (in the value or in what is tested) is in the
automation's time zone, and so are
weekdayandtime_between. Durations are a whole number ands,m,hord; a minus points back (-2his two hours ago). - Patterns are regular expressions in Python's syntax, read in any case. To keep one pattern from tying up the
worker on a crafted email, a pattern that repeats a part that repeats itself (
(a+)+), repeats alternatives that may start alike ((a|aa)+) or refers back to a group (\1) is refused when it is saved, a pattern reads at most the first 20,000 characters of a value, and a match that takes over 50 ms counts as no match. Compiled patterns are cached. - Mentions are the entities an activity is tagged with (
asset.processed,entity.changed). A type is its code or its label in any case (PERSON,person,Organisation;ORG,PRODUCT,PLACE,EVENT,WORK,TERM,DATE,NUMBER). In a name,*stands for any run of characters and?for one, in any case, and every other character is itself; a name with neither is the whole name (Acmedoesn't find “Acme Robotics”,Acme*does). A value that is only text is a name. With no path the test reads the activity'stag; in the If give the path (event.tag). The name is matched in time linear in its length, however many stars it has, so a crafted pattern can't tie up the worker. When St Mary's or any person is mentioned in a processed asset:
{"as": "said", "on": "event", "kinds": ["asset.processed"],
"where": {"any": [{"op": "mentions", "value": {"name": "St Mary*"}},
{"op": "mentions", "value": {"type": "PERSON"}}]}}
env:match.test(tree, value, env)reads time and state fromenv, never from globals, so tests and dry runs can fix them:now(the clock when not given),timezone(UTC when not given) andstate.changedcompares with the last value the same test saw for the same automation and trigger, kept inautomation_state(kindlast) by amatch.State(db, automation, alias, dry=False)inenv["state"]. Make one State per activity tested, so the If and a node testing the same thing agree. Withdryit reads and keeps nothing. Without a State nothing has changed. With a path invalue(actor.id), each sensor or sender keeps its own last value. Like every test, it is only reached when the tests before it in anallpass, sochangedcompares with the last value seen when it was reached.automation_stateholds what automations keep between activities. Each automation keeps at most 1000 rows; past that the oldest go and are counted (match.dropped(db, automation)). Alastvalue is kept 30 days after it was last seen;match.tidydrops expired rows once an hour, from the routine schedule.
Looking back¶
Built (match.seen). An automation's If (and only its If, so testing a trigger's where on each new activity stays
cheap) can ask whether something happened before: a seen stands where a test would.
{"seen": {"kinds": ["email.received"], "within": "2d", "same": "object.from",
"where": {"path": "object.subject", "op": "matches", "value": "trip|outing"},
"count": {"at_least": 1}}}
- It finds activities of
kinds(and the kinds under them) published in the lastwithinbefore now, passing its ownwhere, and, withsame, holding the same value at that path as the activity that started the run (the run context'sevent). That activity itself doesn't count. countis{"at_least": n}(1 when not given),{"at_most": n}or both, up to 100:{"at_most": 0}reads "if nothing like it came", as in "if I haven't replied within 2 days".- It reads at most 200 activities, of the automation's namespaces (
env["spaces"]) and as the automation's person (env["viewer"], orenv["account"]) may see them;env["db"]andenv["cfg"]give the database. Without a database it finds nothing. Itswhereis tested with the sameenv(so its clock too) and can't testchanged. - In words:
at least one of “Email received” in the last 2 days, with the same sender.
In a workflow, a condition, filter or switch case takes either one test, as before, or a tree in match; with a
tree, path (when given) is the part of the value the tree tests. Graphs saved before keep running unchanged.
{"id": "school", "type": "condition",
"config": {"path": "object", "match": {"all": [{"path": "from.0", "op": "contains", "value": "@stmarys.org"},
{"not": {"path": "attachments", "op": "gt", "value": 5}}]}}}
Run context¶
What an automation's If, its nodes and its templates see (automation_spec.context_paths() lists the keys, with
titles, for the variable picker):
| Key | Holds |
|---|---|
event |
the activity that started the run (for a join, the last one) |
events |
each trigger's activity by its name, such as events.mail |
trigger |
the trigger that fired: {as, on} |
automation |
{id, name, version, spaces} |
run |
{id, dry} |
namespace |
{id, name} |
user |
who the automation acts for: {id, name, email} |
now, now_local, timezone |
when the run started, in UTC and in the run's time zone, and that time zone |
vars |
the automation's and its namespace's saved variables |
steps |
what each node passed on so far, by node id, such as steps.n3 |
The event workflow scope¶
Built: what an automation does is a workflow of scope event (app/domain/automation_nodes.py), saved and
versioned like any workflow (POST /api/v1/workflows with "scope": "event"). Its input node passes on the
run context, and between the primitives (condition, switch, set, merge, loops...) it has two nodes of
its own:
- Action (
action, configured{action, params, delay?, when?}): one action from the registry. When it runs, it fills its params in from the run context ("text": "New: {{ event.object.name }}"; a param that is exactly one{{ }}expression keeps its type, so a list stays a list; the action's defaults are filled in first, so a default that is a template, likeask_agent'sabout, is filled in too; a prompt param is rendered with outside text marked, as The model and the agent says), works out its effect and risk and makes an intent. A routine intent is carried out there and then: what it gives back passes on alongoutand goes insteps.<node id>for later nodes. One that asks first waits for the person's pick, and one with adelay(like30m) or a time (when, a time or a template giving one) waits for its time: such a node passes nothing on, so the nodes after it are skipped and the run's log says where it stopped and why. An action that fails is marked failed on its node, and the rest of the graph goes on. - Decide (
decide, configured{question, options, input?, min_confidence?}): the decision model picks one of its options, with an output port per option andunsure(The decide node).
Checked when it is saved, on every path (create, a new version, a body):
- It needs an action node, each action is one the registry has (
GET /api/v1/automations/actionslists them) with params that fit its schema, and each template in its params is valid. - The leaf rule: an action that reaches outside Lens or other people (effect
out, worked out from its params) or that waits has nothing connected after it, as it can't pass anything on while it waits for a yes or its time. - Waiting before an action takes a
delay(more than 0, like30m) or awhen(a time, or a template giving one), not both; it is kept as a timer (Actions that wait).
Its automation runs it: an event workflow isn't run on an asset, as a pipeline step or as a routine's action, and the
canvas's list of workflows leaves it out (GET /api/v1/workflows?scope=event lists them).
Try it with POST /api/v1/workflows/test (admins): {"scope": "event", "graph": ..., "kind": "asset.added"}
runs it on that kind's sample, and "activity": "<id>" on a real activity you can see. Nothing is kept and nothing is
sent: each action says what it would do (would write “New: Call with the school” to the run log), and intents
lists each one with its params, effect, risk, status (done, waiting, scheduled or failed) and what it gave
back. Text from outside Lens (lens.untrusted on the activity, or what a node passed on when its output says
untrusted, like a web page's answer) that reaches an action's params marks its intent untrusted.
On the canvas¶
Built: an event workflow opens on the workflow canvas (/workflows/<id>) like any other. It starts from What
happened, the run context. The palette lists every action in the catalog under Actions; dropping one adds an
action node set to it, and its settings are a form drawn from the action's params (a template field takes {{ }}),
with a badge saying whether it runs on its own or asks you first, and Wait first for a delay. Decide sits
under AI: write its question and its options one a line as name: what it means, and each option becomes an output
port, with unsure last. The canvas says what the backend would refuse before you publish: an action not chosen or
a required param left empty, a wait that isn't like 30m, a decide node without a question or with fewer than two
options, and the leaf rule (nothing connected after an action that reaches outside Lens or waits). Try on a
sample runs the graph on the sample of the kind you pick: nothing is kept or sent, each node shows what it passed
on, and each action says what it would do (run at once, ask you first or wait for its time).
The decide node¶
Built (match.check_decide, match.run_decide). A decide node asks the decision model (Jev, then Laya, then the
language model as a fallback, as in Routine choices) a judgement call, and the run
goes on along the port of the option it chose:
{"id": "n4", "type": "decide", "config": {"question": "Does this need me today?",
"options": {"urgent": "Needs an answer today.", "later": "Can wait."}, "min_confidence": 0.8}}
optionsnames 2 to 10 options, in lowercase letters, digits and_, each with what it means. The node's ports are the options, thenunsure; wire an edge from each port you want to act on.inputis a template of what the model reads, rendered as prompts are (each value from outside Lens marked as data), withinputholding what reached the node:Subject: {{ event.object.subject }}. With none, it reads the event's summary and its outside text (the fields inlens.untrusted), marked as outside text (untrusted.wrap). Either way instructions in an email are read as content.- The run goes along the chosen option when the model is sure enough:
min_confidence, elsedecisions.act_above(default 0.8). Otherwise it goes alongunsure, as it does when no decision model or language model is set up, or the budget's hard limit is reached. Along the port passes the decision,{choice, confidence, ranked, by, sure}, andsteps.<node id>gets{port, choice, confidence, sure}(choiceis its best guess when it went alongunsure):{{ steps.n4.choice }}. - The call is made inside the run's activity scope, so it shows in the activity ledger under the automation and
the run, and counts towards budgets. An answer is kept for 7 days per activity and question (in
automation_state), so a retry or a dry run (Try it) of the same activity doesn't pay again; a dry run does call the model the first time. No model is called unless an automation has a decide node, and then only for activities that reach it.
Actions and intents¶
Built: the registry (app/domain/actions.py). An action is registered with actions.register(ActionSpec(name,
label, group, params, perform, required=(), effect="read", risk=None, output=None, describe=None, subject=None)):
params is a JSON Schema of type object, perform(db, cfg, intent, user, dry) carries it out and gives a dict (with
dry, it says what it would do and changes nothing). actions.catalog() lists every action for the builder and
agents, actions.get(name) gives one, actions.check_params(spec, params) checks params and fills in defaults (a
string holding {{ }} stands for any type, since it is filled in when the action runs), and actions.perform(db,
cfg, intent, user, dry=False) checks an intent and carries it out.
When an action node runs, it makes an intent, checked by automation_spec.check_intent:
{"key": "51-n7", "action": "notify", "params": {"to": "me", "text": "School trip on Friday"}, "effect": "self",
"risk": "routine", "untrusted": true, "spaces": [3], "run_as": 1, "node": "n7", "automation": 4, "run": 51,
"when": null, "status": "waiting"}
Its status is waiting, scheduled, done, failed, declined or expired. The action's effect decides the
risk (actions.resolve_risk(spec, params, ctx, policy), where policy is the node's pick or the automation's whole
policy with the node in ctx["node"]):
| Effect | Means | Risk |
|---|---|---|
read |
looks things up, changes nothing (a tool asks for any change itself) | routine |
self |
tells or keeps something for the automation's own person only | routine |
lens |
changes something inside Lens | routine on the event's own object in the automation's namespaces, else ask |
out |
sends outside Lens, reaches other people, or deletes | ask, unless the person set the node to auto |
An effect may be worked out from the params (notify to "me" is self, to a room is out); when it can't be
worked out, it counts as out. An action's own risk function may make it stricter, never looser.
An action acts for the automation's person (run_as), with their permissions as they are when it runs
(actions.person), within the automation's namespaces (actions.reach): an automation whose person was switched off
or removed does nothing. What it writes is by the automation itself, automation:<id> (actions.actor).
| Action | Does | Effect | Output |
|---|---|---|---|
log_line |
puts a line (text, level info or warning) in the run's log |
read | {line, level} |
notify |
sends text (and a link; Lens's own paths become full links) to me (your own chat app), target:<id> (one of the namespace's notification targets) or gateway:<name> (a Matterbridge room) |
self for me, else out |
{to, text, sent, failed}: where it went |
share_with_circle |
shares the activity (activity, by default the run's) or an asset with your circles (by name or id) and people in them, with a note, and with_item to let them open the asset (Circles of trust) |
out | {share, circles, people, granted} |
ask_model |
asks the model a prompt (with your own system instructions and model if you like); with json_schema (a JSON Schema of type object) the reply is that object |
read | {text}, or the object the schema describes |
call_tool |
calls one of the assistant's tools (tool, with args) as the automation |
read: a change becomes an approval | {tool, result, summary, approvals} |
ask_agent |
hands what happened (about, by default the event) to the agent with your instructions, limited to some tools if you like, for up to max_steps rounds (6, at most 12) |
read: changes become approvals | {answer, chat, approvals, steps} |
http_request |
sends a method (GET, POST or PUT) to a url, with headers and a body (text, or an object sent as JSON) |
out | {status, ok, body, json, content_type, truncated, method, url, headers}: the answer, as outside text |
run_workflow |
runs a recording workflow (its published version, or the one named) on an asset as a job |
lens | {job, workflow, version, asset} |
run_pipeline |
processes an asset again with a pipeline (or the one its namespace uses), keeping its outputs |
lens | {job, pipeline, asset} |
enhance |
enhances an image asset whatever the settings, keeping the original, and reads it again from the copy |
lens | {job, asset} |
run_routine |
asks the scheduler to run a routine now (propose_only to only propose its changes) |
lens, so it asks | {routine, requested, propose_only} |
set_field |
sets one of an asset's custom fields (field: its id or label) to value (empty clears it) |
lens | {asset, field, label, value, changed} |
add_to_collection |
moves an asset into a collection of its namespace |
lens | {asset, collection, name, moved, newly_see} |
write_note |
writes text (Markdown) on a notebook page (with a title, about a thing, in a namespace) or on an asset (shared or not) |
self for a private note on an asset, else lens | {on, page or note, added, url} |
Telling a person¶
notify to me goes through channels.tell(db, cfg, account, text, links=()): every chat channel that answers as
your account (the Telegram bot, the Slack app) keeps the private chat it last had with someone on its list of people
who may talk to Lens (chat_route:<channel>, updated as it answers), and the message goes there. A group or a room is
never used, since it reaches other people, and neither is Matterbridge, where a gateway is a room. With no such chat
the action fails with "no chat app linked", saying how to link one. The result names each channel and chat it used,
so a reply there can be matched to what was sent.
Chat apps fetch what a message links to show a preview, so a web address built from what the run read would carry it
out before anyone clicks. When the run read text from outside Lens (the intent's untrusted), web addresses in the
message are broken up (hxxps://evil[.]example/...), except Lens's own; when it read none, the person wrote them and
they stay. The same goes for notification targets and Matterbridge rooms. A notification target must be in one of the
automation's namespaces, switched on, and the automation's person must be able to edit that namespace; a Matterbridge
room needs the bridge on and answering as the automation's person (or the person to be an admin).
The model and the agent¶
ask_model, call_tool and ask_agent use the assistant's tools as the automation, with its person's permissions
within the automation's namespaces, their extensions and no server tools. Nobody reads along while an automation runs,
so every change asks the person: a tool that changes something makes an approval (notes included, which the assistant
in chat writes at once), and a tool that could carry data out of Lens (an extension's web or server tool) asks first
whatever the run read. The approvals are the person's (GET /api/v1/approvals), with the run as their origin
({automation, run, node}); the output lists their ids, so a later node can tell you what waits.
ask_agent saves the exchange as one of your conversations, titled "
Params marked "format": "prompt" in an action's schema (ask_model's prompt, ask_agent's instructions) are
prompts: they are rendered with variables.prompt (Variables and templates), which marks as data
(<untrusted-…>) what the run read from outside Lens before the model sees it: each activity's lens.untrusted
fields (an email's subject and body), everything earlier steps passed on (a model's reply, a web page) and saved
variables. Your own words in the prompt stay as written. A prompt param may name a saved prompt template instead of
its text. ask_agent passes the activity it is about the same way.
Calling a web address¶
http_request reaches public addresses only, or networks an admin allowed for notifications (Settings →
Notifications), connecting to the address it checked and following no redirect (a 3xx is passed on as it is). It takes
10 seconds at most, sends a body of up to 64 KB, and passes on the status and the first 64 KB of the answer (json
too when the answer is JSON and whole). The answer is outside text: a later prompt reads it marked as data. It always
reaches outside Lens, so it asks first unless you set its node to run on its own; each call goes in the activity
ledger (automation.http, with the method, host and status).
Credentials go in headers. A header whose name says it carries one (Authorization, cookies, API keys, tokens,
secrets, passwords, signatures) is never shown: what the action describes and passes on says ••••. When an automation
is saved, actions_web.seal_headers(db, cfg, space, automation, url, headers) seals those values with the namespace's
key (Encryption), bound to the automation and to the scheme, host and port of its address, so a
sealed value can't be copied into another automation or sent to another host; it is opened only to send. A locked
vault stops the request until it is unlocked.
Running Lens work¶
run_workflow and run_pipeline take an asset: an asset id, or the activity's object (lens:recording/<id>, which
is the default, {{ event.object.id }}). On the asset the activity is about, in the automation's namespaces, they run
without asking; on any other, they ask first. They need the automation's person to be able to process assets in that
namespace, queue a job (after whatever the asset's job still has to do) that shows as by "automation: run_routine asks the scheduler to start a routine's run within half a minute, as Run in Settings
does: routines are an admin's, the routine must work only in the automation's namespaces, a run over one of its budgets
is refused, and it always asks first. The run's id shows in the routine's runs.
Changing assets and writing notes¶
set_field and add_to_collection take an asset like the Run actions and need the automation's person to be able
to edit assets in that namespace. A field's change goes in the asset's metadata history as by "automation:
write_note writes a notebook page in the namespace of what it is about (an asset, entity, topic, collection or
speaker, which has one page each: a second note about it goes at that page's end, its history keeping the rest), or
the one named, or the automation's only namespace; the page is written by the automation. on: asset writes a note on
the asset instead, as yours: a private one (the default) only you see, so it runs at once, while a shared one
everyone who reads the asset sees.
Calendar events aren't written: Lens signs in to Google Calendar read-only, and writing needs a new consent. Meanwhile the agent can draft an event as a note.
Sources and the event log¶
Built. A source (events.Source, added with events.register_source) reads rows Lens already keeps as activities,
so nothing is stored twice:
read(db, cfg, pos, spaces, limit, hint): activities strictly afterpos([time, id], or[time, null]for after every row at that time), oldest first, at mostlimit, of the namespaces inspaces(null: all).hintis narrowing a source may use, like{"actors": ["lens:sensor/5"]}: what the triggers that want its kinds let through.page(db, cfg, before, viewer, flt, limit): newest first, strictly beforebefore(keyof the last one shown), with what the viewer may see and the filters it can apply cheaply (the stream checks every filter again).get(db, cfg, activity_id): one activity by its id, or null;now(db),back(db, seconds): positions of now and ofsecondsago;key(a): the positionpageorders by.
A kind belongs to the source named in its source, and a source may list more in kinds. These are built:
recordings(app/domain/events_content.py) readsrecordingbycreated_at, the id breaking ties, asasset.addedand its children (Kinds, above). The actor is whoever added it: an automation (its first job was queued byautomation:<id>), the storage source or mailbox it came from (Service,lens:source/<id>), an import hook (Service,lens:import_hook/<id>), the person whose upload, clip or import it was (Person,lens:account/<id>, orlens:email/<address>for someone who isn't an account), a sensor's digest (Service,lens:sensor/<id>), or Lens (a folder scan).events_content.actors(db, labels)names them the way Lens writes them down (an account id, an email,watch:<id>,routine:<id>,import_hook:<id>,automation:<id>, ...), in a few queries for a whole page, and keeps names for 30 seconds.jobs(app/domain/events_content.py) readsjobbyfinished_at, the id breaking ties, asasset.processedandasset.failed; their ids carry the time they finished (processed-job-51-1792081450), so a run tried again is a new activity.commentsreadscommentbycreated_at;note_pagesreadsnote_pagebycreated_at;note_versionsreadsnote_versionbyat(to the millisecond) and keeps one version for each page and window;graphreadsgraph_eventbyat, the event number breaking ties (numbers are given out in order, so the two agree).sensor_readings(app/domain/events_streams.py) readssensor_readingbyat(microseconds), the id breaking ties, one sensor at a time, assensor.reading;sensorsreads thestorage_sourcerows discovery made bycreated_at, assensor.joined.routine_runs(app/domain/events_streams.py) readsroutine_runrows that ended byfinished_at, the id breaking ties, asroutine.ran.chat_messages(app/domain/events_streams.py) readschat_messagerows with role user bycreated_atover the index on (role, created_at), the id breaking ties, aschat.message.approvals(app/domain/events_approvals.py) readsapprovalbycreated_at, the id breaking ties, asapproval.waiting;approval_decisionsreads the decided ones bydecided_at, asapproval.decided. Both page only the viewer's own approvals.shares_sent(app/domain/circles.py) readscircle_sharebyat, the id breaking ties, asshare.sent;shares_receivedreadscircle_share_to(one row for each person a share went to) the same way, asshare.received. Both page only the viewer's own; a follower of some namespaces doesn't readshares_received.agent_asks(app/domain/circles_ask.py) readsagent_askthe same way, asagent.asked, for the person asked.logreadsevent_log: activities no row of their own records (a deletion, a webhook call), written byevents.emit(db, kind, *, actor, obj, space, summary, target=None, tag=None, key=None, published=None, spaces=None, audience=None, owner=None, untrusted=None), which gives the id (log-<n>). Emitting akeyagain changes nothing and gives the first id, so a retry never doubles an activity. Insidewith events.caused_by({"automation": 4, "run": 51, "depth": 1}):what is emitted carries that cause. It is read by(logged_at, id)and paged bypublished, and its rows are kept forevents.keep_days(30; Settings → Automations).automations(app/domain/events_automations.py) readsautomation_runbyfinished_at, the id breaking ties, asautomation.ran: a run that ran its graph (done, partly, failed or waiting), with the automation as its actor (Application,lens:automation/<id>), the run and what its actions did as its object, and what started it as itslens.causewhen that was another automation's run. An automation over a limit is logged asautomation.limited, at most once an hour each.
Following the stream¶
Built. The API and lens worker are separate processes on one database, so following works the way notifications
do: events.follow(db, cfg, consumer, kinds=None, spaces=None, limit=500, hint=None) gives (activities, commit),
the activities since the consumer's last commit, oldest first. Handle them in order, then call commit() to save
where the next look starts (event_cursor:<consumer>, a position per source).
- A consumer seen for the first time starts at now: nothing that happened before replays, not even through the lookback. What happens in the second it starts in isn't seen.
hintgives each source its own narrowing, by source name:{"sensor_readings": {"actors": ["lens:sensor/5"]}}(match.compile_triggersmakes it, as Running automations says).- Only
kinds(and their descendants; null: every kind) inspaces(null: every namespace and none). A source that makes none of the kinds wanted isn't read, and its cursor moves to now, so a kind listened to later doesn't replay a backlog.events.idle(db, consumer)moves every cursor to now. - A source with more waiting gives
limitat a time. Once caught up, a look also reads the last 60 seconds again, for a row written as the last look ran, minus what this process already handled. So claim each activity before acting on it:events.claim(db, consumer, key)is true for the one process, and the one time, that claimskey(an activity's id, say), with aCREATEofevent_claim:<hash>only one process wins.events.unclaimed(db, consumer, keys)lists the keys nobody has claimed yet, in one query. Claims go after a day. events.emitwakes the followers in its process; a follower waits between looks withg = events.generation()before the look andevents.wait(events.poll_seconds(cfg), stop, since=g)after it (events.poll_seconds, 5; Settings → Automations).
Timeline reads¶
Built. events.page(db, cfg, viewer, before=None, flt=None, limit=50) reads the stream newest first for a viewer
(events.viewer(account, spaces, admin), spaces null for every one): {"items": [...], "next": ...}, where next (an
opaque cursor, null at the end) goes in before for the page after. It merges every source's page by published
(each source's own position breaking ties), applies who may see what, and when filters thin a page out it reads on, up
to five pages from each source. events.get(db, cfg, id, viewer) reads one activity, null when there is none or it
isn't the viewer's to see.
- Who sees what:
lens.audienceowneris its owner alone;adminsis admins;spaceis everyone who reads one of its namespaces (spaceorlens.spaces); admins read every namespace, and an activity of no namespace is for admins. People who were given only some collections of a namespace don't see its activities yet. - Filters:
kinds(with their descendants; a noisy kind only when named),actor(an actor's id, or several),spaces(namespace ids),tag(an entity's id or name),since(published after) anduntil(at or before),q(words in the summary) andwhere(a match tree on the activity). events.tidy(db, cfg)drops event log rows pastevents.keep_daysand claims past a day; the routine scheduler runs it once an hour.
Approvals¶
Built: everything that waits for your pick is an approval in one table (app/domain/approvals.py): the assistant's
proposals in a conversation, as before, an automation's actions that ask first, and events proposed from dates said
in an asset (Propose an event), which only you, signed in, approve. When an action node's intent asks
(its effect is out, or lens on anything but the event's own object), the engine hands it to
approvals.ask(db, cfg, intent, event=None), which keeps:
| Field | |
|---|---|
origin |
{automation, run, node}: the run that asked (null for the assistant's own approvals) |
intent |
the intent, with the params as the run filled them in |
summary, automation, why |
what it would do (the action's describe), the automation's name, and the activity that caused it ({activity, kind, summary, published, url}) |
account, space |
the automation's person, whose pick it is, and the namespace it is about |
expires_at |
when it expires: automations.approval_days (7) after it asked |
told |
the chats you were told in |
status |
pending (waiting), running, done, failed, declined or expired; decided_by, decided_at, result, error |
You are told once, in your own chat app when one is linked (Telling a person): "School emails wants to post in the Matterbridge gateway family: “Trip on Friday”. Open to decide." The link opens the approval in Lens. Handing the same intent over again gives the same approval. At most 100 wait for one automation: past that its intents are dropped, and the run says so, so a flood of activities can't bury your inbox or your chat.
Deciding (POST /api/v1/approvals/{aid}):
approvecarries the intent out throughactions.perform, for its person and as the automation (what it writes is byautomation:<id>, model calls count in the activity ledger under the run, and what it causes carries the run as its cause). Its outcome,donewith what it gave back orfailedwith why, goes on the approval and on the run.declinedoes nothing, and the run says it was declined.editwithparamschanges them first (a param given as null is left out), checked against the action's schema; what the run filled in from a template stays as it is now. A credential header shows as••••, and given back as••••it keeps its sealed value.alwaysapproves it, then lets that action node run without asking from now on (the automation's policy,auto). Only an action that reaches outside Lens or other people asks each time, so only those can be allowed.
Approving, editing and always are yours: signed in to Lens, on the approval's card, or approving with a yes in your
own chat app (below). An API key, an app, MCP and the assistant can list and decline approvals, never approve them,
since approving is the person's pick; each approval's can says what the caller may do with it. This is the one
place where an agent can't do what you can: agents read them with approvals and decline with decline_approval
(over MCP and in the assistant), and there is no tool to approve.
The assistant's own approvals (sending mail, adding an event, writing a file, changing entities and the rest) follow
the same rule: you approve them signed in to Lens or in the chat they were asked in, and an API key, an app, MCP and
the assistant can only decline them (403). A research plan is the one exception, and only when an admin turns on
research.agents_skip_review (Deep research).
In your chat app. A reply of "yes" in the private chat you were told in approves an automation's action, under the rules a chat room's approvals follow (Chat rooms), and only when all of these hold:
- the action changes something inside Lens (effect
lens); one that reaches outside Lens or other people is approved on its card in Lens, and the reply says where; - the channel lets you approve there (approving in the room is on, and you are on its list of people who may talk to Lens);
- it is the only thing waiting in that chat: with several, a bare "yes" lists them and "yes 12" picks one ("yes all" leaves an automation's actions waiting, to be picked one at a time).
"No" declines it, whatever it does. The message that told you says "Reply “yes” to do it or “no” not to" only when a yes there would count; otherwise it gives the link.
Undecided approvals expire after automations.approval_days (Settings → Automations, 1–365, default 7): a sweep
called from the scheduler once an hour marks them expired, nothing runs, and the run says so. The assistant's own
approvals don't expire.
Each approval is an activity too: approval.waiting when it asks and approval.decided when it is decided or
expires, seen by its person alone (Approvals as activities). A namespace's notification
target can send approval.waiting to a team chat or webhook (Notifications); it is off
until a target asks for it.
The Approvals page (/approvals, or ⌘K (Ctrl+K) → Approvals) lists what waits for your pick, each automation's
together and the assistant's by conversation: what it would do, why (a link to the activity that caused it), whether
it reaches outside Lens, when it expires, and its params. The first is in focus, so Enter approves it and focus moves
to the next; Tab moves between them. Edit changes the params in place (Enter approves with the changes),
Always allow shows where the node can be allowed, and Decline takes it out at once with Undo on the notice
for a few seconds instead of a question. The link you were told (/approvals?id=12) brings that one first, or says
what became of it. Below, what was decided lately. While anything waits, a badge in the top bar counts it, the nav
has an Approvals item under Automate with the same count, and Home's Needs attention has a row for them; with nothing
waiting, none of them shows, so the nav stays as short as it was.
The inbox reads GET /api/v1/approvals (newest first; status, origin=automation|chat, automation, chat_id,
and before with limit to page), GET /api/v1/approvals/{aid} and GET /api/v1/approvals/count ({waiting,
automations}, for a badge).
Time¶
Time is a trigger and a step: "every weekday at 7:00", "at 9:00 on the 20th", "30 minutes before a calendar event", "wait 2 hours, then...", and the clock behind a join's "if no reply within 2 days". All of it rests on timers.
Every automation's schedule and at trigger is also a row on the Schedules page, next to the routines and the backup, where it is switched, re-timed and run now.
Timers¶
Built (app/domain/timers.py). A timer is one row per key in the timer table: when it is due, what runs then (its
kind), what that gets (its payload), and the automation it is for. Status: waiting, then fired, or failed,
cancelled or missed.
timers.set(db, key, at, kind, payload, automation=None) # makes it, or replaces the one with that key
timers.cancel(db, key) # True when one was waiting
timers.cancel_for(db, automation) # every waiting one of an automation (deleted, switched off)
timers.get(db, key)
@timers.handler("join.absent") # what runs when one of this kind fires
def _(db, cfg, payload): ... # payload + late (true or false) + timer {key, at, automation}
- Once. A thread started with the routines' threads (so in the API's background work and in
lens worker) looks every 5 seconds, and at once afterevents.wake(). Each due timer is claimed with a compare-and-set update that only one process wins, then its handler runs. A process that stops while a handler runs leaves that firing done: a timer fires at most once, never twice. - Repeating. A handler registered with
again(payload, after)(a schedule's next time) is re-armed in the same update that claims it, so a repeating timer is never lost; its row keeps waiting, withlastsaying how the last firing went. - Lens was off. A timer due within
automations.catch_up(1 hour; Settings → Automations) fires late, withlate: true; an older one ismissedand counted on its automation (missed). A repeating one that missed several times fires once, for its latest time if that is within the window, then waits for its next. A timer fired within 2 minutes of its time is on time. - Kept small. An automation has at most 1000 timers waiting; setting more is refused, saying so. Timers that
ended are dropped after 7 days, by a tidy
routines.run_duecalls (once an hour). A kind nothing handles fails with a note. - Light. No timer exists until an automation uses time. While none waits the thread doesn't query the table: it checks once a minute, and at once when this process sets one. A timer set by the other process (the API, when the worker fires them) can so fire up to a minute late.
Schedule, at and relative triggers¶
Built as timers: saving an automation sets them, and when they fire they start a run (Running automations). Each trigger checks as Automations: triggers and joins says.
timers.schedule_automation(db, automation) # {id, enabled, spaces, triggers}: on save, switch on, switch off
timers.relative(db, automation, alias, activity, field, offset) # a matching activity came: {key, at, set, note}
timers.upcoming(db, automation, n=10) # what it has waiting, soonest first, in words
timers.clock_state(db, automation) # each schedule and at trigger: waiting, done, missed, failed or off
- Schedule (
on: schedule,cron,timezone): one timer,sched-<automation>-<as>, at the schedule's next time (schedule.py: five-field cron, the shorthands, time zones; a time daylight saving skips runs when the clock gets past the gap, a time it repeats runs once). When it fires, the automation fires with aclock.tickactivity for that moment, and the timer is re-armed for the next time in the same update. - At (
on: at,at,timezone): one timer,at-<automation>-<as>, if the time is still to come; afterwards the trigger reads done, and saving the automation again doesn't set it again. - Relative (
on: relative,kinds,where,field,offset): when a matching activity comes, a timer at the time in itsfieldplusoffset(-30m: half an hour before; a date alone is its midnight in UTC), keyed by the automation, the trigger and the activity's object (rel-<automation>-<as>-<object id>). So when a calendar event moves (calendar_event.changed, about the same object) its timer moves with it rather than adding a second. A time more than 5 minutes in the past is skipped, with a note for the automation's log, and a timer it had is called off. When it fires, the automation fires with the activity that set it. - Saving again leaves a timer a trigger still wants as it is, so one that came due while Lens was off can still
catch up. A changed schedule or time sets it anew, a trigger that's gone has its timer called off, and waiting
relative timers follow a changed
fieldoroffset. Switching an automation off calls off every timer it has. - What's coming (
upcoming):{key, at, kind, trigger, node, description, untrusted}, such as "Every weekday at 07:00: next on Fri 16 Oct at 07:00 (Europe/London)" or "30 minutes before “Parents' evening” starts: on Tue 20 Oct at 16:30 (UTC)";untrustedsays the description quotes outside text (the event's title). The builder previews a schedule withGET /api/v1/routines/schedule, which now reads weekdays, single days and days of the month in words too ("every weekday at 07:00", "at 09:00 on the 20th of every month").
Actions that wait¶
Built as timers; the automation's run hands them over. An action node with a delay
(2h) or a when (a time, or a template giving one) makes an intent with that time and status scheduled, and
timers.later(db, intent) keeps it as the timer intent-<intent key> (its run and node, like intent-51-n7), giving
back {timer, at} to keep with the intent. The run's page shows it as "scheduled for 18:22".
When its time comes, its risk is worked out again (timers.risk_now) from the automation as it is then, not as it
was when the run made the intent:
readandselfrun on their own.lensruns on its own only if it did when the run made it (the event's own object) and its namespaces are still the automation's.outruns on its own only while the person still has that node set to run on its own; otherwise it asks.- The action's own check may only make it stricter. The effect is the stricter of the intent's and what its params give now.
A routine one is carried out through actions.perform as the automation (its person's permissions as they are then),
inside its run's cause (what it makes carries lens.cause, for the loop guard) and its ledger scope
(automation:<id>, automation_run:<run>). One that should ask becomes an approval
(approvals.ask). The run hears of each step through automations.update_intent: done
with what it gave back, failed with why, waiting, or expired when the automation was switched off or removed first,
the timer was cancelled, or Lens was off past the catch-up window.
Variables and templates¶
Built (app/domain/variables.py). An action's params and prompts are templates over the run
context: sandboxed Jinja, the same sandbox as Lens's other templates (templates.py: no reach into
Python, no huge strings, a 2 MB cap on what one renders). A template reads everything the run knows, so one line can
join two sources: {{ events.mail.object.subject }} on {{ events.soon.object.startTime | date('%a %H:%M') }}.
variables.context(db, cfg, automation, run, trigger, event, events, user, now=None) # the run context
variables.render(value, ctx, untrusted_paths=None, where="") # params, dicts and lists walked
variables.prompt(template, ctx, untrusted_paths=None, cfg=None, db=None) # {text, rules, system, schema, template}
The run context¶
The keys are those of Run context. now is when the run started in UTC (2026-10-15T16:22:09Z) and
now_local the same moment in the run's time zone (2026-10-15T17:22:09+01:00). The time zone is the trigger's
(a schedule's or an at's), else the first one the automation's triggers name, else UTC. namespace is the event's
namespace when the automation works there, else its first.
Params¶
- A param that is exactly one expression keeps its type:
"{{ steps.n3.count }}"gives the number 3,"{{ event.object.from }}"a list,"{{ event }}"the whole activity (a copy). Anything else renders as text. - Dicts and lists are walked, so a param such as
{"to": ["{{ user.email }}"]}is filled in where it stands. a.breads a dict's keybfirst, then a method of that name, so an action's output{"items": [...]}reads assteps.n3.items(Lens's other templates keep Jinja's own order).- An error names the param and the variable that isn't there, with the nearest name there is, and the template's
line:
text: events.mail.object.subjct is not defined; did you mean subject?, orto: user.zzz is not defined; it has email, id, name. A path guarded bydefaultoris defineddoesn't count. - Text from outside Lens keeps its words in params (a message forwards the subject as it was written), without the
invisible characters that would smuggle instructions to a model that reads it later. The intent still says it
read outside text (
untrusted), and outbound actions treat it so.
Prompts¶
A prompt (a param with "format": "prompt") renders with every value read from outside Lens marked as data with
untrusted.wrap: the source is the activity's kind and actor (email.received from Work Gmail), and anything that
looks like a marker or a chat model's control token is broken up. That covers each activity's lens.untrusted
fields, everything earlier nodes passed on (steps) and the saved variables a run set from
outside text. The person's own words, Lens's own fields (a summary, ids, times) and numbers stay as written.
Filters and string methods work on the words of marked text and give back marked text, so | plain | truncate(4000)
or .upper() can't drop or cut the markers; comparisons (==, in, length) read the words themselves, so {% if
event.object.subject == "Trip" %} works as written.
rules is the system prompt's paragraph about marked text, to add to the system prompt (the AI actions add it
themselves); with ai.untrusted_marking off nothing is marked and rules is empty.
A prompt param may name a saved prompt template (Settings → Templates, POST /api/v1/templates) instead of its text:
{"template": 5} for its current version, {"template": 5, "version": 2} to pin one. It is checked when the
workflow is saved (the template must be there and be a prompt template). The template's own instructions and output
schema fill ask_model's system and json_schema when the node doesn't give them, so the same prompt serves
pipelines and automations.
Filters¶
Every template in Lens has these, on top of Jinja's own (truncate, first, join, default...) and Lens's json
and tc:
| Filter | Gives |
|---|---|
date(fmt, tz=None) |
a time written with a strftime format, in tz (default the run's time zone): {{ x | date('%a %d %b, %H:%M') }} |
local |
a time in the run's time zone, as ISO 8601 with its offset |
ago |
how long ago a time was, from the run's now: 3 hours ago, in 2 days, just now |
plain |
HTML as plain text, a line per block |
emails |
the email addresses in text (or a list of texts), lowercased, each once |
defang |
web addresses broken up so a chat app doesn't fetch them (hxxps://evil[.]example/) |
Times may be ISO 8601 text (a Z, an offset, or none for UTC; a date alone stays a date), epoch seconds or
milliseconds.
Examples¶
A message joining two triggers (a notify node's text):
{{ events.mail.object.subject }} from {{ events.mail.object.from | emails | first }}: you have
{{ events.soon.object.name }} {{ events.soon.object.startTime | ago }} ({{ events.soon.object.startTime | date('%a %H:%M') }}).
A prompt with outside text marked (ask_model's prompt, with a json_schema asking for {trip, date}):
Does this email from {{ event.object.from | join(', ') }} ask us to sign anything for a school trip?
Subject: {{ event.object.subject }}
{{ event.object.content | plain | truncate(4000) }}
The model sees the subject and the body between <untrusted-…> markers, with the rules that say marked text is
material to read, not instructions to follow; your question stays as written.
A counter (a set_variable node count, then a notify node after it):
{"action": "set_variable", "params": {"name": "door_opened", "op": "increment"}}
The front door has opened {{ steps.count.value }} times.
Saved variables¶
An automation keeps values between runs as vars.<name>: a counter, when it last alerted, the last ten senders. Each
is the automation's own, or its namespace's, which every automation working there reads (an automation's own wins on
a name). A run reads them as they were when it started; a set_variable node changes them for the runs after it and
gives what it set as steps.<node>.value (and .previous) for the nodes after it in the same run.
The set_variable action (group Run, routine: it changes nothing outside the automation) takes:
| Param | |
|---|---|
name |
lowercase letters, digits and _, starting with a letter, up to 40 |
op |
set (default) to value, increment by value (a number, default 1), append value to a list of the last 100, or clear |
value |
any JSON up to 16 KB, often a template: "{{ event.object.from | emails | first }}" |
scope |
automation (default) or namespace |
namespace |
for scope: namespace, its id; the automation's only namespace when left out. Changing a namespace's variables needs the automation's owner to be an editor there |
Two runs changing one variable at once both count: a write that loses the race reads the value again (up to five
times). A scope keeps up to 100 variables. A variable set from a template that reads what the run got from outside
Lens (its activities, earlier steps or such variables) is marked so, and prompts then wrap it as outside text; other
variables stay as written. People change them with
GET/PUT /api/v1/automations/{id}/vars and /api/v1/namespaces/{name}/automation-vars (API),
and agents with the automation_vars and set_automation_vars MCP tools.
The variable picker and preview¶
Both work on an automation as it is being written (a draft: {triggers, join?, workflow?, spaces?, id?, name?},
with workflow a saved workflow's id or a graph, which may be unfinished), so the builder can help before anything
is saved:
variables.catalog(db, cfg, draft, node=None, user=None) # [{path, type, label, example, group, untrusted}]
variables.preview(db, cfg, template, draft, user, activity=None, trigger=None, node=None, as_prompt=False)
POST /api/v1/automations/variables {draft, node?} lists what a template there can read, for the picker that opens
on {{:
- the run context's keys and their fields (
user.email,namespace.name,now...); - each trigger's activity,
events.<as>.<field>, from its kind's schema (a schedule's or anat's is the clock'sclock.tick, a webhook'swebhook.received);event.<field>for the fields all the triggers' kinds share; a batch's activities as a list (events.mail.0.object.subject); - the saved variables there are (the automation's own when the draft has its
id, and its namespaces'); - with
node, what the action and decide nodes before it pass on,steps.<id>.<field>, from each action's output schema (anask_modelwith ajson_schemagives the object it describes).
Each has its type, a label from the schema's titles, an example (from the kind's sample, or the variable's value),
a group for the picker's headings and untrusted for text from outside Lens, which prompts mark as data.
POST /api/v1/automations/preview {template, draft, activity?, trigger?, node?, prompt?} shows what a template gives
before it is saved: against a real activity by id (404 for one you can't see) or the kind's sample of the trigger
that fires (trigger, by its as; default the first). A param's value keeps its type and text is it written
out; with prompt (or a saved prompt template, {"template": 5}) the text is what the model will see, outside text
marked, with rules. The steps before node are stand-ins named by their path ([steps.ask.trip]), as a preview
runs no node; a run's real values come from a test run. A template that fails answers with error and line, like
text: events.mail.object.subjct is not defined; did you mean subject?; a draft that doesn't check answers 400.
Anyone signed in may use both, with their own namespaces (none named: theirs) and only an automation they may change.
Agents have the automation_variables and preview_template MCP tools.
Saving automations¶
Built (app/domain/automations.py, POST /api/v1/automations). An automation acts for the
person who made it (run_as), with their permissions, in namespaces they can edit:
{"name": "Say new assets", "triggers": [{"as": "new", "on": "event", "kinds": ["asset.added"]}],
"if": {"all": [{"path": "event.object.media", "op": "equals", "value": "audio"}]},
"graph": {"nodes": [{"id": "i", "type": "input", "config": {}},
{"id": "a", "type": "action", "config": {"action": "log_line",
"params": {"text": "New: {{ event.object.name }}"}}}],
"edges": [{"source": "i", "target": "a"}]}}
- Namespaces (
spaces): ones its person can edit. Left out, it is each namespace they can edit; an admin's works in every namespace (null), now and to come. - The Then is a
graph(The event workflow scope), saved as a workflow of scopeeventnamed after the automation, or a saved event workflow (workflow, withversion, or none to follow the published one). Saving a new graph saves a new version of its workflow, which it then follows. - Credentials in an
http_requestnode's headers (Calling a web address) are sealed when its graph is saved, with the key of its first namespace (the first there is, for one that works everywhere), bound to the automation and to the node'surl. A value holding{{ }}is filled in when it runs and stays as written, and a credential needs theurlwritten out, not a template. Reading the automation back gives the sealed value, and saving that again keeps it. - Checks when it is saved: the triggers, the join and the If as in the contract,
the graph, the namespaces,
limits(cooldown, like10m, default0s;max_per_hour, 1 to 3600, default 60) and the policy. Saving sets the timers of itsscheduleandattriggers (Schedule, at and relative triggers), and switching it off or deleting it calls them off, with any its joins set. - Webhook triggers get their address when the automation is saved (Webhook trigger): the
answer's
hooksgives each new one'surland signingsecret, shown this once, and the trigger keeps the hook's id. Saving it again keeps its address; a webhook trigger dropped (or changed to another kind) loses it at once. - Switching (
POST /automations/{id}/enable {"on": true}) records when it was last switched on (enabled_at): it reacts only to what happens after that, so switching one on never replays old activity. New automations are on unlessenabled: false. - Deleting switches it off and hides it;
POST /automations/{id}/restorebrings it back, switched off. After 30 days a deleted automation is removed for good, with its runs and its workflow (unless another automation runs it). - The policy (
{action node id: ask or auto}): a node whose action reaches outside Lens or other people, or changes something in Lens other than the event's own object, asks first. Only the person, signed in to the web app, lets a node run on its own (POST /automations/{id}/policy {"node": "a", "value": "auto"}); API keys, apps and agents get a 403, and saving through any other route refusesauto(a node already onautomay stay so). A node that leaves the graph takes its pick with it. - Who sees it: admins see and change every automation; anyone else the ones that work only in namespaces they can edit.
GET /api/v1/automations/actions lists what an action node can do: each action's params (a JSON Schema), its effect
and what it gives back.
Describe it in words¶
Built (app/domain/automation_draft.py). POST /api/v1/automations/draft {"text": "when a recording is added to pods,
log its title", "spaces": [3]} asks the model once to turn the sentence into a draft, with the kinds of activity and
their fields, the match operators and the actions as its vocabulary. The answer
is {draft, dropped, unmapped}:
draftis whatPOST /automationstakes, switched off: its name, triggers (on activities, a webhook, whose address comes when it is saved, by hand, or in time: a schedule, a time, or a time in an activity such as a meeting's start, in UTC unless the sentence names a zone), If and a graph with its actions in order (an action that may reach outside Lens hangs off the one before it, as the leaf rule has it).droppedsays what in the model's draft didn't fit and why: a trigger, itswhere, the If, an action that doesn't exist or params that don't fit its schema. With no trigger left it runs when you run it; with no action left it has no Then yet.unmappedlists what the sentence asked for that it couldn't express, such as "when both arrive" (a draft doesn't make joins yet; add one before saving).
Nothing is saved: try the draft (POST /automations/test), change it, then save it. The call counts in the activity
ledger for you and its namespaces, and isn't made once a budget they count for, or everything's, is
used up.
Starters¶
Built (app/domain/automation_starters.py). GET /api/v1/automations/starters lists ready-made drafts, each
{id, name, description, draft}, with draft what POST /automations takes, switched off. Nothing is stored: a
starter is saved only when someone saves it, and runs only once it is turned on. The description says what to change
first, such as the school's address. In the web app they are under Describe it in words on a new automation.
| Starter | What it does |
|---|---|
| Tell me about school emails | An email from an address with “school” in it tells you in your chat app; posting it to the family Matterbridge room asks you first |
| Router alerts | An alert in a sensor's log whose name has “router” in it tells you, at most once an hour |
| Morning digest | Every weekday at 07:00 (UTC until you change it) the agent sums up yesterday's timeline items you haven't reviewed, then tells you |
| No reply in two days | An email whose start asks something, with no email in your Sent mailbox within two days, reminds you; any email you send in that time calls it off |
| Webhook to note | A call to its webhook writes a note with what was sent |
Router alerts' "at most once an hour" is a limit; the builder keeps it when you save.
Agents¶
Built. Over MCP an agent uses automations (list them, read one with its latest runs, or one run),
automation_actions, save_automation, test_automation and run_automation, with the same checks as the API; a
body that doesn't fit comes back in plain words, naming the field. A new automation is saved switched off unless the
agent says enabled: true, and saving and running need a token with the write scope.
The assistant has automations, save_automation, test_automation and run_automation. Saving
there is made at once and always switched off; switching one on, or running one now, becomes an approval card the
person decides, which says what it listens for and the actions it takes. Switching one off is made at once. Neither
MCP nor the assistant lets a node run on its own (auto): only the person, signed in to the web app, does.
Running automations¶
Built (app/domain/automations.py). A thread named automations runs where the notifier and routines do (the API's
background work and lens worker). It follows the stream (Following the stream) for the
kinds that switched-on automations' triggers listen for, every events.poll_seconds and at once when something is
emitted or an automation is saved. With no automation switched on (or automations switched off in Settings), it only
moves its place to now, so nothing replays when one is switched on.
Which triggers listen comes from match.compile_triggers(automations), an index of the switched-on automations'
stream triggers (event, relative, and webhook for webhook.received): kinds (every kind they listen for, to
follow), by_kind (for each kind, the kinds under the ones named included, the triggers listening, as {automation,
as, trigger, row}; match.candidates(index, activity) reads it) and hint. For each source whose every listening
trigger names the actors it takes (actor.id equals or in in its where), the hint gives that source
{"actors": [...]}, so a busy source, such as sensor readings, is read only for the sensors someone listens to.
Each new activity is checked against each trigger that listens for its kind (a webhook trigger listens for
webhook.received calls to its own hook), and skipped when:
- it happened before the automation was last switched on (
enabled_at); - an automation made it, unless the trigger says
from_automations; - it is the end of a chain of automations setting each other off as long as
automations.max_depth(3); - it isn't in one of the automation's namespaces, or the automation's person may not see it;
- the trigger's
wheredoesn't match it (in the trigger's time zone; achangedin it compares with the last activity the trigger saw).
Then it is claimed (for the automation, trigger and activity together), so it runs each automation once, even with the
API and a worker both following. automations.fire(db, cfg, automation_id, alias, activity, events=None, dry=False)
is the one way every trigger starts a run (the stream with its webhook calls, run now, the clock, timers and joins);
with a join other than any, the claimed activity goes to the join first (Joins) and the automation fires
when it completes. It gives the run's id, or none when nothing ran. A run goes:
- Its person: if the person it acts for lost their account, their admin role (for one that works everywhere) or the right to edit one of its namespaces, the run fails saying so, and the automation is switched off.
- The If, on the run context, with the run's clock and time zone: a
seenin it looks back in the automation's namespaces as its person may see, andchangedcompares with what the automation saw last (Operators). The If and the graph's condition, filter and switch nodes share one State a run, so a node testing what the If tested agrees with it; a dry run keeps nothing. When it is false the run stops there: it counts incounters.skipped, uses up no limit and makes no run and no activity. - Limits: the automation's
cooldown(the wait between runs) andmax_per_hour, then every automation's runs together this minute (automations.max_runs_per_minute, 60). Over one, nothing runs: it counts incounters.limitedand says so in the stream (automation.limited) at most once an hour. - Budgets on the automation, its workflow and its namespaces (Budgets): over one, the run is held
for your pick (status
held, one at a time; later ones add to itshold.missed) or skipped, as its budget says. - The graph runs as its person, counted for
automation:<id>and its run in the activity ledger: graph changes are marked as made by the automation, and activities it makes carry the run as their cause. Routine actions are done; ones that ask first wait for the person's pick (Approvals) and ones with a time wait for it (Actions that wait). - It finishes
done,partly(some actions failed),failedorwaiting(actions wait), with its cost and tokens, what each node did (trace:done,skipped,failed, orwaitingandscheduledfor an action that asks first or waits for its time), its log (at most 500 lines) and anautomation.<status>row in the activity ledger; and it appears in the stream asautomation.ran. When a waiting action is decided or its time comes, its status changes (automations.update_intent) and the run's follows.
Runs are kept for automations.keep_runs_days (30); each automation keeps its last 20 however old they are, and a
held run until you pick.
Over the API (/api/v1/automations; admins see every automation, anyone else the ones in namespaces they edit):
GET /automationslists them with their counters, last run, actions waiting and cost this month, and for the list in the studio:reads, what sets each one off in words (match.describe_triggers: “Email received”, where the subject contains “trip”; every weekday at 07:00 (Europe/London)),runs_today(since midnight UTC, tries left out) andnext_at, its soonest schedule, at, relative or join timer.GET /automations/{id}/runs?before=<run>pages its runs, newest first;GET /automations/runs/{run}gives one with its trace, log and what its actions did (intents);GET /automations/runs?activity=<id>the runs an activity started.POST /automations/{id}/testtries a saved one, andPOST /automations/test {"draft": {...}}one you haven't saved, on an activity ("activity": "<id>") or a kind's sample ("kind", default its first trigger's): whether its If passes and what each action would do. Nothing is kept, sent or counted, and limits and budgets aren't checked.POST /automations/{id}/runruns it now, on an activity you name (in its namespaces, one its person can see), else the newest one its triggers listen for, else as started by hand. Limits, budgets and asking first apply.POST /automations/runs/{run}/decide {"run": true}runs a held run now, once, whatever its budgets say;{"run": false}skips it.GET /automations/{id}/upcominglists what it has waiting on the clock, soonest first: its schedule,atand relative times and its actions that wait (Time).
Limits and costs¶
An automation runs as often as what it listens for happens, so each one has limits, and what it spends is counted. Running automations says where in a run each is checked.
| Limit | Default | Set in |
|---|---|---|
The wait between one automation's runs (cooldown, like 10m) |
none (0s) |
its limits, over the API or save_automation |
One automation's runs an hour (max_per_hour, 1 to 3600) |
60 | its limits, over the API or save_automation |
Every automation's runs together, a minute (automations.max_runs_per_minute) |
60 | Settings → Automations |
A chain of automations setting each other off (automations.max_depth) |
3 | Settings → Automations |
How long runs are kept (automations.keep_runs_days; each automation keeps its last 20 whatever their age) |
30 days | Settings → Automations |
How long an action waits for your pick (automations.approval_days) |
7 days | Settings → Automations |
| Actions waiting for your pick, per automation | 100 | fixed |
| Timers waiting, per automation | 1000 | fixed |
What an automation keeps between activities (automation_state rows) |
1000 | fixed |
| Saved variables, per automation or namespace | 100 | fixed |
A run over a limit doesn't run: it counts in the automation's limited counter and shows in the stream as
automation.limited, at most once an hour. The builder keeps an automation's own limits but doesn't show them yet;
change them over the API or MCP (Refine later).
Costs. Following the stream, testing triggers and conditions, telling you, and changing things in Lens call no
model. A model is called only by ask_model and ask_agent (The model and the agent), by
a decide node an activity reaches, and for a draft from a sentence. Those
calls, and each http_request, go in the activity ledger under the automation (automation:<id>) and
its run. A budget on the automation (set on its Costs tab), its workflow or its namespaces holds a run
that would go over it for your pick, or skips it, as the budget says. The list shows each automation's cost this
month, and each run what it cost.
The API and MCP tools¶
Everything in the studio is open to agents. People and agents use the same API (/api/v1, API),
and agents also have MCP tools (MCP). The assistant has automations, save_automation,
test_automation, run_automation, timeline, mark_activities, mute_activities, approvals and decline_approval. Asked how to
do something in this guide, the assistant and MCP clients look it up here with lens_help.
| To | API | MCP |
|---|---|---|
| List automations, read one with its runs, read a run | GET /automations, GET /automations/{id}, GET /automations/{id}/runs, GET /automations/runs/{run} |
automations |
| See what actions can do | GET /automations/actions |
automation_actions |
| Make or change one, switch it on or off | POST /automations, PATCH /automations/{id}, POST /automations/{id}/enable |
save_automation (in the assistant it saves switched off and asks you to switch it on) |
| Try one, keeping and sending nothing | POST /automations/test, POST /automations/{id}/test |
test_automation |
| Run one now | POST /automations/{id}/run |
run_automation (in the assistant, once you approve) |
| Draft one from a sentence, or start from a starter | POST /automations/draft, GET /automations/starters |
none yet |
| Delete and restore | DELETE /automations/{id}, POST /automations/{id}/restore |
none yet; automations lists deleted ones (removed) |
| Let an action run on its own | POST /automations/{id}/policy, signed in to the web app |
none: only you do |
| Run a run held over budget, or skip it | POST /automations/runs/{run}/decide |
none yet |
| What's coming up | GET /automations/{id}/upcoming |
schedules lists its schedule and at triggers (Schedules) |
| Saved variables | GET/PUT /automations/{id}/vars, /namespaces/{name}/automation-vars |
automation_vars, set_automation_vars |
| The variable picker and a template's preview | POST /automations/variables, POST /automations/preview |
automation_variables, preview_template |
| Approvals | GET /approvals, GET /approvals/count, POST /approvals/{aid} |
approvals, decline_approval (never approve) |
| The stream and its kinds | GET /activities, GET /activities/kinds, POST /activities/mark, POST /activities/mute |
timeline, event_kinds, mark_activities, mute_activities |
| Set an automation off from outside | POST /automations/hooks/{token} (Webhook trigger) |
not needed: anything that sends HTTP calls it |
Built, in progress, planned¶
The studio is built in modules; this table labels each part, and only what is merged is called built. What isn't built yet is in Refine later; the Timeline has its own table.
| Part | Status |
|---|---|
| Match trees: one condition language for triggers, an automation's If and workflow nodes | built |
Operators for times, lists, text patterns and changes (in, matches, within, changed...) |
built |
Looking back over the stream in an automation's If (seen) |
built |
Joins: all, count, absent and batch, completed as the stream brings each trigger's activities and as their timers fire (Joins) |
built |
Activities, the kinds catalog, the event log, following the stream, timeline reads (app/domain/events.py) |
built |
New assets as asset.added (the recordings source) |
built |
| New assets by kind: emails received, calendar events, files from storage, uploads, pages saved from the web, imports | built |
| Runs that finished or failed, deletions and calendar events that moved | built |
| Comments, notes written and saved, and changes to the entity graph | built |
| Sensors: readings a sensor keeps, alert and notable log patterns, devices that report for the first time (Sensors) | built |
What people say to Lens, in the web app and chat apps, as chat.message (Chat) |
built |
| Lens itself: routine runs, budget warnings, backups and new releases (Lens itself) | built |
The timeline API (GET /api/v1/activities), the MCP tools timeline and event_kinds, the assistant's timeline |
built |
Review marks on the timeline (reviewed, important, hidden), their filters and mark_activities (Timeline) |
built |
Reactions and comments on activities, as reaction.added and comment.added (Timeline) |
built |
The shapes of triggers, joins, an If, policy and intents, checked (app/domain/automation_spec.py) |
built |
The event workflow scope: what an automation does, drawn with action nodes and tried on an activity (app/domain/automation_nodes.py) |
built |
The decide node: the decision model picks a branch, one port per option and unsure (The decide node) |
built |
| Drawing an automation's workflow on the canvas: the actions in the palette, their settings from each action's params, Decide's ports, the leaf rule and a try on a kind's sample (On the canvas) | built |
The action registry with risk from each action's effect, and log_line, a line in the run's log (app/domain/actions.py) |
built |
Telling a person: the notify action and channels.tell (their own chat, a notification target, a Matterbridge room) |
built |
Telling your circles: the share_with_circle action, which asks first unless you let it run on its own (Circles of trust) |
built |
The model and the agent: the ask_model, call_tool and ask_agent actions, and prompts with outside text marked |
built |
| Templates in params and prompts: the run context, one expression keeping its type, errors that name the nearest variable, saved prompt templates, filters for times, text and addresses (Variables and templates) | built |
Saved variables: vars in the run context, the set_variable action, the API and MCP tools (Saved variables) |
built |
| The variable picker's catalog and a preview of what a template gives, through the API and MCP (The variable picker and preview) | built |
Calling a web address: the http_request action, with credentials in headers kept sealed |
built |
Running Lens work: the run_workflow, run_pipeline and run_routine actions |
built |
Changing assets and writing notes: the set_field, add_to_collection and write_note actions |
built |
| Settings → Automations: whether automations run, their limits, how long runs are kept and how late a timer may fire; how often followers look and how long the event log is kept | built |
A webhook address per automation trigger, and calls to it as webhook.received (Webhook trigger) |
built |
Timers that outlast a restart and fire once across the API and the worker (app/domain/timers.py, Time) |
built |
The timers behind schedule, at and relative triggers, the clock.tick kind and what's coming (Time), set when an automation is saved |
built |
Actions that wait (delay, when): kept as timers, carried out at their time, asking first when they should then (Time) |
built |
| The Timeline page with filters, grouping, review keys and new items, and Home's Latest card (Timeline) | built |
Approvals: an automation's action that asks first waits for your pick (approve, decline, edit, allow from now on), told once in your chat app, capped and expiring (app/domain/approvals.py) |
built |
| Deciding an automation's action with a yes in the chat it told you in | built |
Approvals as activities (approval.waiting, approval.decided) and the approval.waiting notification |
built |
Agents' approval tools: approvals and decline_approval over MCP and in the assistant, and no way for an agent to approve |
built |
The Approvals page (/approvals), its badge in the top bar and nav, and Home's Needs attention row |
built |
Saving automations: checks, namespaces, switching on and off, delete and restore, the policy, the API (app/domain/automations.py) |
built |
Running automations: following the stream, limits, budgets, runs as automation.ran, trying and running now, the API (app/domain/automations.py) |
built |
| Automations for agents: the MCP tools and the assistant's, switching on and running now by approval | built |
Describing an automation in words: a draft from a sentence (POST /api/v1/automations/draft) |
built |
The studio: the list at /automations and the When, If and Then builder, with a test panel (The studio) |
built |
| An automation's page: its runs, what's coming up, its variables and its costs, in tabs (An automation's page) | built |
Starters: five ready-made drafts, switched off, to open, change and save (GET /api/v1/automations/starters, Starters) |
built |
Refine later¶
- A draft from a sentence that makes joins ("when both arrive", "if no reply within 2 days").
-
Switching an automation off and on keeps what an
allorcountjoin was waiting on, until its window passes; a changed join keeps the old one's rows until they expire. -
Variables inside match values (
{"var": "vars.threshold"}). - Statistical conditions, such as a reading above its 7-day average.
mentionson an asset's top fifty entities: an asset that mentions more names only its most mentioned; a picker for the entity and its type in the canvas's condition node (today it is written in a where or over the API and MCP); anentity.mentionedkind, one per entity, if one per processed asset proves too coarse.- Sequences longer than two steps ("A, then B, then C").
- The canvas shows and edits match trees on condition, filter and switch nodes (today it edits one test; a tree saved through the API is kept).
- A
?format=as2JSON-LD view and per-actor outboxes for ActivityPub; SurrealDBDEFINE EVENTfor Update verbs that no row records; pushing instead of polling. - People given only some collections of a namespace see that namespace's activities about those collections.
- A timeline page filtered to one child of
asset.added(only emails, say) reads every new asset and keeps those of that kind, up to five source pages; narrow it in the query (path,remote,web) if that proves slow. - Moves of Google Calendar and iCal events only: events in a calendar file on storage are read as new files. An event that was cancelled or deleted from its calendar isn't an activity yet.
- Folder scans of a namespace's own folders as
file.added; uploaded.icsfiles with their event's fields; a snippet for IMAP messages before they are read (it needs the message body). - Per-recipient threading of email conversations; entity tags on new assets once extraction runs at ingest.
note.savedcomes as a window's first change is made, while the person may still be typing; an automation can wait a little before it reads the page (Wait first, Actions that wait). A page made and then written on shows bothnote.createdandnote.saved.- A graph change across namespaces is for admins; show it to whoever reads all of its namespaces, as the graph's history does.
- Every processed asset's analysis is an
entity.changed(op created or changed); the timeline page could fold them into the asset'sasset.processed. - SurrealDB 3.2.4 gives a range's first rows, not its last, for
ORDER BY ... DESC LIMIT nover an index, so sources write a page's time bounds so that no index range is used, and its cursor (events.after_sql) reads the table in full. Paging is right; give it one ordered index read per page once SurrealDB does that right. - Telling a person: buttons on chat approvals (Telegram inline keyboards, Slack Block Kit); email as a
notifychannel; web push; private chats through Matterbridge (it can't tell them from rooms); with several people let in, "me" is whoever last wrote privately, until each chat user has a Lens account of their own; telling which links in a message the person's template wrote, so they stay when the run read outside text. - Variables and templates: secrets as variables (sealed, never rendered into prompts); typed variable declarations
with defaults per automation; a time zone of each person's own, rather than the triggers'; wrapping outside text
the same way in
tool_nodes.ask_model(pipeline workflows) and extensions' prompt tools; an activity when a person changes a variable; marking a variable from outside text only when the value itself came from it, not whenever the run read some; removing an automation's variables when it is removed for good; a preview that runs the nodes before the template's (dry) rather than standing in for their outputs; the picker listing a saved prompt template's output schema for the node that uses it; matching on variables in triggers and the If. - The model and the agent: per-node model picks in the studio; streaming an agent's steps into the run's log as they
happen; a namespace assistant's memory for automations (
rememberis left out today); approving an agent's proposals from the chat app it told you in. - Calling a web address: secrets in the address itself (a Slack or Discord webhook URL) sealed like headers; PATCH and DELETE (DELETE would count as deleting); OAuth sign-in for an API; form-encoded bodies; retries with backoff; networks allowed for automations apart from notifications.
- Time: an action that waits shown in the person's time zone in
upcoming(today UTC); calendar-aware schedules ("first workday of the month", holidays); sunrise and sunset triggers; an all-day event's date in the person's time zone rather than UTC; upcoming relative timers in the person's time zone; an event that's cancelled calling off its relative timers; the timeline's kind filter leaving outclock.tick, which is never stored; sub-second timers woken byevents.wake()alone; telling the other process (the worker) at once when the API sets a timer, rather than within a minute. - Running Lens work: a routine run's id passed on (today the scheduler starts it later); waiting for the job and passing
on its outputs; roles on collections counting for
process; running a pipeline on every asset a join collected. - Changing assets: asking first instead of failing when a move would show an asset to new people (risk that reads the
database); fields on collections and files; tags; roles on collections counting for
edit. - Writing calendar events (a new Google consent, so it changes existing installs).
- Webhook trigger: signed answers to the caller (what the run did); a rate limit shared by every API process (today each counts its own); showing a hook's last call in the studio.
- Sensors: a threshold per reading in the intake (only readings over a value become activities, for sensors that keep everything); a reading's unit, which sensors don't record yet; an alert as soon as a pattern is labelled, for the lines already seen that hour.
- Chat: recording every message in a room as an activity (opt-in per channel, off by default); a chat user who is also a Lens account as that person; a timeline page of others' private messages is read and left out, up to five source pages, so narrow it by account if that proves slow.
- The studio: dragging actions and rows to reorder them (today the arrows move an action); sharing an automation as a
file; a builder laid out for phones first; the builder showing and editing an automation's own limits (
cooldown,max_per_hour), which today it keeps as they came. On a phone, Save and turn on sits under the bottom bar, so making one from a starter takes a swipe (5 taps and a swipe, against 4 clicks on a computer, in the clicks survey). - Agents: MCP tools for drafting from a sentence, the starters, delete and restore, and deciding a held run (today
through the API); the assistant's own
automation_actionsand variable tools. - Starters: "No reply in two days" matching the reply to the same conversation (emails carry no thread yet, so any email you send calls it off); a starter picking its sensor, room or address from what is connected, instead of a word to change.
- Credentials in the headers of a saved event workflow an automation follows (
workflow) sealed too (today only a graph saved with the automation is; the workflow's are sent as written). - Webhook triggers: a trigger option for a hook that takes only signed calls (
token_for(..., signed=True)), and a new address for a trigger without saving it again. - Lens itself: a run held over budget, waiting for someone's pick, as an activity of its own; backups taken before
upgrades or with
lens backup(only those from Settings, the API and the schedule are activities today). - Approvals: a cost estimate on actions that ask (a run on another asset could say what it would cost); approve buttons in Telegram and Slack (today a yes decides only actions inside Lens); approvals that need two people; one card for many alike ("approve all 12"); trying a failed one again; expiring an automation's approvals when it is removed; on the page, keys for decline and edit, live updates instead of reading again each minute, and the assistant's proposals shown with their estimates.
- Limits count in fixed hours and minutes; a sliding window would spread a burst at the turn of the hour.
- Tell the person when a run is held over budget or an automation goes over a limit (today it shows in its runs, its last status and the stream).
- Automations that listen for every kind (or hundreds of automations): an index of triggers by kind and
where, so a look reads each activity once whatever the number of automations. - Drafts from a sentence: the person's own time zone when the sentence names none (today UTC).
- Tests: two decisions on one approval at the same moment (today "decided once" is tested one after the other); the real caps (100 approvals, 1000 timers, 64 KB and 60 calls a minute per hook), which the tests check with smaller numbers.