agent
Runs an agent definition. The firing shows up as a normal ops.agent_runs row, with the trigger as its provenance.
A trigger is a page in the triggers/ directory that says “when X happens, do Y.” X is either an event (a mail arriving, a calendar update, a page changing, an agent run finishing) or a schedule (a cron expression). Y is an action: run an agent, start a workflow, or run a sandboxed function. Triggers turn Subspace from a place you check into a place that watches things for you, without you writing a background service.
Every trigger is an ordinary knowledge base page: you can open it, edit its body, OKF-dump it, and manage it through gitops like anything else. The contract lives in metadata.trigger, and every firing pins the exact trigger content that fired.
metadata.trigger on a triggers/ page looks like this:
trigger: on: event: types: [mail-received] where: { field: "sender.email", contains: "@acme.com" } action: agent: triage-vip enabled: trueon is either an event source or a schedule source:
{ event: { types, where?, fnPredicate?, match?, dir?, coalesceSeconds?, debounce? } }{ schedule: { cron } }action is one of:
{ agent: <ref>, prompt?, args? }: run an agent. ref is the agent definition’s page id, title, or slug. prompt overrides the definition’s prompt; both the definition prompt and the override interpolate {{field}} placeholders flatly from {type, ...args, ...payload}.{ workflow: <registry name>, args? }: start a registered workflow directly.{ fn: <code/ page ref>, args? }: run a sandboxed function with no agent run and no LLM cost.enabled: false turns a trigger off without deleting the page, so you can keep disabled example triggers around as templates.
Event triggers listen to types, an array combining ops.events types with a few synthesized kinds:
| Type | Fires on | Payload |
|---|---|---|
mail-received |
An inbound mail message lands in an account you own | {messageId, threadId, accountId, from, sender: {name, email}, subject, snippet} |
mail-sent |
You send a mail message (own sends never fire mail-received) |
same shape, plus to |
cal-updated |
A calendar event is created or changes | {eventId, title, start, attendees} |
page-created / page-changed |
A page is created or edited under a directory you name | {pageId, title, slug, dir} |
run-finished |
An agent run reaches a terminal status | {runId, agentPageId, agentSlug, status} (status is done or failed) |
Mail and calendar triggers ride the same mail and calendar change feeds their domain consumers use, including backfill, so one inbound message is one fire. Comms are personal: a trigger owned by a user only sees that user’s own mail and calendar rows; an ownerless trigger sees everyone’s.
Page triggers require a dir (a directory slug) and only fire for pages inside it, because without that scope every keystroke in the knowledge base would be noise. System-origin writes (agent tools, recompute, triage, gitops, OKF import, plugin install, sandboxed functions, meetings, CRM, judge trends) never fire page triggers, which keeps automation from triggering itself in a loop. A run started by any trigger never fires run-finished triggers for the same reason.
where matchers AND together against the event payload:
field: a dot-path into the payload, e.g. sender.email or payload.status.equals: compares against the stringified value.contains: a substring test.Event triggers evaluate cost-ordered, so an LLM call only happens once an event has already passed the free checks:
Declarative gate
types and where run in-process against every ops.events row. Free, and the only stage that runs for every event in the system.
fn predicate (optional)
fnPredicate names a code/ function page. The event {type, payload} is its single argument; it must return a boolean. It runs in the QuickJS sandbox with the default ~2 second interrupt and a read-only grant (the curated core tools minus kb.append, no network) regardless of what the installed plugin’s manifest allows. A failure, timeout, or non-boolean return counts as no-match, and the error sticks in the trigger’s state until the next clean evaluation, visible in the settings list.
LLM match (optional)
match is a natural-language condition judged by one cheap model call using SUBSPACE_MODEL, with a strict JSON {match} response and a 5 second timeout. A matcher failure logs as a no-match rather than blocking the lane. Because this is the expensive stage, types stays mandatory even for match-only triggers, so the cheap gate always narrows the field first.
This staging is why triggers stay cheap at scale: a trigger watching for “urgent customer emails” only spends a model call on messages that already matched mail-received and any declarative filters, not on every event in the system.
{ schedule: { cron } } triggers sync into the scheduler on boot and whenever a page in triggers/ is edited. Each fire dispatches the action after the scheduling commit, claimed on the occurrence so redelivery can’t double-fire it. An invalid cron expression surfaces as the trigger’s error in the settings list instead of silently doing nothing. Disabled schedule triggers are removed from the schedule table; enabling one re-adds it. Schedule-kind triggers also show up on the schedules settings page.
Two distinct ways to tame noisy sources:
debounce: { seconds, key? } (up to 3600 seconds): a trailing-edge debounce. Matching events collect per bucket (key is a payload dot-path, e.g. pageId; omitting it means one bucket per trigger) and the trigger fires once per quiet window, after events stop arriving. The payload is the last event’s fields plus batch (up to 50 payloads), batchCount, and debounceKey. An in-process timer sweeps due buckets, and a boot sweep catches windows that elapsed while the server was down.coalesceSeconds (page-event default 60): a leading-edge coalesce. The first event fires immediately; subsequent events in the window are swallowed. A burst of edits to the same page fires the trigger once, not once per keystroke.Use debounce when you want the settled state after a burst (batch summarize everything that changed); use coalesce when you want an immediate reaction that shouldn’t repeat while the burst continues.
agent
Runs an agent definition. The firing shows up as a normal ops.agent_runs row, with the trigger as its provenance.
workflow
Starts a registered workflow directly, anchored on the trigger page itself.
fn
Dispatches a sandboxed function with no agent run and no per-firing model cost.
The fn action snapshots its target like any run-bound function: pinned source plus capabilities, with arguments mapped by parameter name from {type, ...args, ...payload} (a parameter literally named event receives the whole envelope). If the snapshot’s capability grant fits inside the curated default grant, or inside its installed and enabled plugin’s approved manifest (including brokered credentials approved at install time), it executes unattended. Any wider grant instead parks a needs-confirm card: Confirm runs the pinned snapshot, Dismiss runs nothing. Either way the outcome is recorded against the firing so it isn’t re-evaluated on redelivery.
A trigger fires as its own page’s owner. Action references (agent, workflow, or fn) resolve through the owner’s read permissions, and the run’s tools check the owner’s live page ACLs, not the credentials of whoever last edited the trigger page. That keeps a shared trigger’s blast radius predictable: it can only do what its owner could already do.
An in-process dispatcher consumer evaluates every enabled event trigger against every ops.events row as it lands; declarative filters cost nothing, so this scales to a busy event stream. Every firing is claim-guarded, so outbox redelivery never double-runs an action, and the claim row is the join between a run and the trigger that started it, which is also what powers “last fired” in the UI. One broken trigger (for example, a dangling agent reference) logs an error and does not stall the rest of the lane.
This is distinct from the older metadata.agent.on field some agent pages still carry: that stays as a lighter sugar for triggering an agent from its own page, with its own claim namespace, while triggers/ pages are the general-purpose mechanism.
Because an LLM match filter can silently no-match on a phrasing you didn’t anticipate, every trigger has a dry-run affordance that evaluates the full ladder without creating a claim or dispatching anything:
triggers.test({ pageIdOrName: "vip-mail-triage", event: { type: "mail-received", payload: { sender: { email: "ceo@acme.com" }, subject: "Q3 numbers" } }})The response reports every stage (declarative, fnPredicate, match) plus the action that would run if this were a real firing, so you can see exactly which stage a near-miss failed at.
/settings/triggers (sidebar → settings → triggers) lists every trigger with when, action, last-fired, and next-run columns, plus an on/off toggle. The when column shows a config error (bad cron, bad schema) or the last sticky predicate error in place of the normal summary. Clicking a trigger’s name opens the underlying page.
The create row lets you build a trigger without hand-editing YAML: pick an event or schedule source, an agent or workflow action, and an optional prompt override. The prompt override field is a plain textarea, since prompts run long and benefit from multi-line editing. Event-kind triggers in the list also get a test button that runs the dry-run ladder inline and shows the result per stage.
Phase-two fields not exposed in the create row (fnPredicate, match, dir, and fn actions) are set by editing the trigger page’s metadata.trigger directly, since these are more advanced and less common than plain declarative filters.
The relevant tRPC calls: triggers.list, triggers.save({name, on?, action?, enabled?}) (updating by name preserves whichever half you don’t touch), and triggers.test({pageIdOrName, event: {type, payload}}).
---title: vip-mail-triagemetadata: trigger: on: event: types: [mail-received] where: { field: "sender.email", contains: "@acme.com" } action: agent: mail-triage prompt: "A message just arrived from {{sender.email}} with subject \"{{subject}}\". Decide whether it needs same-day attention." enabled: true---Declarative gate only: no model cost until mail-triage itself runs.
---title: research-heartbeat-dailymetadata: trigger: on: schedule: cron: "0 9 * * *" action: agent: research-heartbeat args: goal: goal-page-ref enabled: false---Shipped disabled as a copyable template: duplicate it per research goal, point args.goal at that goal’s page, choose a cadence, and enable it. One shared research-heartbeat agent definition backs every goal’s trigger, so the definition never needs to be cloned. See autoresearch for the goal model this feeds.
---title: log-coding-runsmetadata: trigger: on: event: types: [run-finished] where: { field: "status", equals: "done" } action: fn: code/append-run-log enabled: true---Runs the code/append-run-log sandboxed function directly on every successful run completion, with no agent loop and no LLM call per firing.
---title: project-digestmetadata: trigger: on: event: types: [page-created, page-changed] dir: projects debounce: { seconds: 900, key: "dir" } action: agent: digest-writer prompt: "Summarize what changed across {{batchCount}} pages in the last 15 minutes: {{batch}}" enabled: true---A burst of edits across the projects/ directory settles into one summarization run 15 minutes after the last edit, instead of one run per edit.
---title: urgent-escalationmetadata: trigger: on: event: types: [mail-received] match: "the sender is asking for something to be resolved today or is expressing frustration about a delay" action: agent: escalation-notifier enabled: true---types still gates for free; the LLM match only runs on messages that already passed as mail-received, keeping the per-message cost to one cheap classification call at most.