Event-driven workflows
Status: opt-in, off by default. Introduced in #150 (Knowledge OS epic, Workflow-Engine layer). This is the first ZettelFlow feature that lets a flow run without a manual launch.
Normally a flow runs because you asked — the ribbon icon, the Open workflow command, or a vault hook on the active file. Event-driven workflows let a flow react to what happens in the vault instead: a note being created can auto-start a flow, a property flip can advance a piece of knowledge. This turns Obsidian from a store you operate into a system that helps knowledge evolve.
Because a flow that runs on its own is powerful (and potentially surprising), the feature is disabled by default, throttled, and loop-guarded — a flow can never retrigger itself.
Turning it on
Settings → ZettelFlow → Event-driven workflows → Enable event-driven workflows.
With the toggle off (the default) no listeners are registered and no flow can fire — behaviour is byte-for-byte identical to a ZettelFlow without this feature. Toggling arms/disarms the listeners immediately; no reload is needed. Everything is torn down on plugin unload (no leaks).
The trigger vocabulary
A flow reacts to one event. The vocabulary is fixed; v1 wires the cheap, deterministic four and reserves the rest for a later slice:
| Event | Fires when | v1 |
|---|---|---|
note.created |
a markdown note is created | ✅ wired |
note.modified |
a markdown note's content changes | ✅ wired |
property.changed |
a frontmatter property's value changes | ✅ wired |
tag.added |
a tag is added to a note's frontmatter | ✅ wired |
note.linked / note.unlinked |
a link is added/removed | ⏳ reserved |
workflow.completed |
a workflow finishes | ⏳ reserved |
review.due |
a note's review cadence lapses | ⏳ reserved |
property.changed and tag.added have no native Obsidian signal — they are derived
deterministically from a frontmatter/tag snapshot diff (the same approach the property hooks use).
Authoring a trigger (v1)
A binding lives in the root step's zettelFlowSettings frontmatter, so the trigger travels
with the flow — install a methodology package from the community gallery and its triggers come
with it. v1 ships no visual authoring (that is the visual WHEN/IF/ACTION/WAIT language, a later
slice); you write the trigger by hand in the root step file:
---
zettelFlowSettings:
root: true
label: Fleeting inbox
trigger:
event: note.created
condition: "return event.notePath.startsWith('Inbox/')" # optional
enabled: true # optional; omit = enabled
---
event— one of the wired tokens above.condition(optional) — azfscript; the binding fires only when it returns a truthy value. It receives the event payload aseventand thezfscript API aszf(the same evaluator behind property hooks and the Script action). Absent = always fire. A condition that throws or is invalid is caught and the binding is skipped safely — it never breaks the vault.enabled(optional) — setfalseto keep the trigger but switch it off.
The Settings → Event-driven workflows panel lists every configured trigger. For a trigger on a file-node root you can toggle it on/off or remove it there; for a trigger embedded in a canvas node, use the open flow action and edit it in the canvas.
Safety model
| Guard | What it does |
|---|---|
| Off by default | No listeners until you opt in; a fresh install fires nothing. |
| Throttle | Per binding per note, a few seconds — a burst (sync, bulk import, folder move) collapses to at most one run per note per window. |
| Loop guard | A workflow's own writes are recognised (via the VaultStateManager freeze/on-process state) and suppressed, plus a bounded depth cap — so a flow that writes the note that triggered it cannot loop. |
| Same execution path | A bound flow runs through the same entry a manual run uses — identical note output, no parallel code path. |
| Lifecycle-owned | Every listener and timer is removed on disable and on plugin unload. |
Caveats
- Conditions run synchronously. A binding's
conditionis azfscript with no timeout — the same execution model as property hooks and the Script action. Keep it cheap: a heavy or infinite condition blocks Obsidian's UI thread. Prefer a small predicate over the note's frontmatter/path. note.modifiedcan be noisy. A binding onnote.modifiedwith a broad or absent condition opens the wizard on every qualifying edit (throttled to at most once per note per window). Scope it with a condition, or prefernote.created/property.changedwhere you can.
Capabilities & privacy
Event-driven workflows observe vault file/metadata events (file-system reads) and, on a fire,
run a flow that may create/modify notes (file-system writes) — both within ZettelFlow's already
disclosed file-system capability. A binding condition runs as a zf script, reusing the
already-disclosed script-execution capability (the same evaluator as hooks / the Script action). No
network calls, no AI — event-driven execution is fully local.
Visual workflow language (WHEN / IF / ACTION / WAIT)
Event-driven workflows compose on the native canvas as a small, readable language — WHEN a vault event happens, IF a condition holds, run an ACTION, then WAIT for you. Each block lowers onto a primitive ZettelFlow already runs, so there is one execution path (the engine above), not a second runtime:
| Block | Is | Where it lives |
|---|---|---|
| WHEN | the event trigger | the root step's zettelFlowSettings.trigger — author it in the step builder's When (event trigger) field |
| IF | a conditional branch | an if: … canvas edge label (conditional edges) |
| ACTION | a step to run | an ordinary Step node, authored with the step builder |
| WAIT | a human-confirmation pause | a new additive wait marker on a step — toggle Wait for confirmation in the step builder, or Mark as a wait step from the canvas node menu |
WAIT — the human-in-the-loop pause
When the wizard reaches a WAIT step it suspends and shows a prompt: Continue resumes the workflow, Cancel (or closing the prompt) aborts it. Nothing is written until the workflow finishes, so a cancelled or dropped WAIT leaves the vault untouched. v1 WAIT is human confirmation only and has no cross-restart persistence — a WAIT still pending when Obsidian closes is simply dropped (it fails safe: no half-built note). Keep it out of unattended, event-triggered flows unless a person is there to answer.
In-canvas legibility
On a ZettelFlow canvas the blocks are visually annotated by kind — WAIT nodes badged, WHEN (trigger) roots and IF edges marked — so a flow reads as an arc of thinking at a glance. This styling is cosmetic only: it changes nothing about execution or storage, and if Obsidian's canvas internals change it simply doesn't apply (the workflow still runs).
Safety
A visually-authored workflow inherits every guard above: off by default, throttled, and
loop-guarded — a WAIT pause never defeats the loop guard (a resumed run's own write is still
suppressed). An IF condition reuses the safe #119 evaluator (no eval); a
malformed condition safe-opens the branch rather than silently dropping it.
See also
- Vault hooks internals — property/folder hooks fire from the same vault signals this engine observes.
- Knowledge lifecycle — a note's state (its maturity) is orthogonal to a step's phase and to these trigger events.