Actions & note builder
This is the functional heart of ZettelFlow: the action abstraction, how each action contributes to the generated note, and the wizard state machine that drives note creation.
1. The action contract
Every action implements ICustomZettelAction (architecture/api/typing.ts) and extends the
abstract base CustomZettelAction (architecture/api/CustomZettelAction.tsx):
interface ICustomZettelAction {
id: string;
component(props: WrappedActionBuilderProps): JSX.Element; // build-time UI
settings: ActionSetting; // design-time config UI
execute(info: ExecuteInfo): Promise<void>; // contribute to the note
postProcess(info: ExecuteInfo, file: TFile): Promise<void>;
getIcon(): string;
getLabel(): string;
}
The base class adds four abstract members: defaultAction: Action,
settingsReader: ActionSettingReader (read-only config view), link: string (doc URL),
purpose: string (one-line description). component, execute, and postProcess have empty
default implementations, so an action overrides only what it needs.
Lifecycle — what runs when
| Member | Phase | Purpose |
|---|---|---|
id, defaultAction |
registration / step editor | store key + the config template cloned by getDefaultActionInfo |
settings(contentEl, modal, action, disableNavbar?) |
design time (step editor) | render the config UI that mutates the action object |
settingsReader(contentEl, action) |
design time | read-only preview of the config |
component(props) |
build time (wizard) | the React UI the user fills in; calls props.callback(value) |
execute(info) |
build | write the action's contribution into ContentDTO / context |
postProcess(info, file) |
after file write | side effects needing the real TFile (e.g. backlinks) |
getIcon(), getLabel(), link, purpose |
menus / docs | presentation |
Key types (architecture/api/typing.ts)
type Action = {
type: string; // "prompt", "script", …
id: string;
description?: string;
hasUI?: boolean; // true → renders a wizard step; false → background action
[key: string]: Literal; // arbitrary per-action config
};
type ExecuteInfo = {
element: FinalElement; // the configured action + its runtime `result`
content: ContentDTO; // note body / frontmatter / tags accumulator
note: NoteDTO; // title, folder, template paths, saved actions
context: Record<string, Literal>; // ephemeral bag shared between actions
};
FinalElement = { result: Literal } & Action.
The 4-file convention
Each action folder (src/actions/<name>/) contains:
*Action.tsx— theCustomZettelActionsubclass (registration +execute/postProcess).*Component.tsx— the build-time React UI (skipped for background actions).*Settings.ts(x)— the design-time config UI (ObsidianSettingrows and/or React).*SettingsReader.ts— the read-only config view (used in community previews).
2. Zones — where a value goes
Most actions route their value by a zone config field:
frontmatter(default) →content.addFrontMatter({ [key]: value })→ a YAML property.body→content.modify(key, value)→ replaces{{key}}placeholders in the merged step-template body.context→context[key] = value→ not written to the note; a shared bag so later actions and scripts can read earlier values.
3. The 11 built-in actions
| # | type (class) |
Icon | hasUI |
Build-time UI | Writes | Key config |
|---|---|---|---|---|---|---|
| 1 | prompt (PromptAction) |
form-input |
✅ | text area | string → zone | zone, key, label, placeholder, static toggle/value |
| 2 | number (NumberAction) |
binary |
✅ | number input | number → zone | zone, key, static |
| 3 | checkbox (CheckboxAction) |
check-square |
✅ | checkbox | boolean → zone | zone, key, static |
| 4 | selector (SelectorAction) |
square-mouse-pointer |
✅ | single/multi select | string(s) → zone | zone, key, options (dnd list), multiple |
| 5 | dynamic-selector (DynamicSelectorAction) |
square-dashed-mouse-pointer |
✅ | select from script output | option(s) → zone | zone, key, script code returning [label,value][], multiple |
| 6 | calendar (CalendarAction) |
calendar-days |
✅ | date/time picker | moment-formatted date → zone | key, zone, enableTime, format, static |
| 7 | backlink (BackLinkAction) |
links-coming-in |
✅ | target/heading picker | nothing here — inserts [[thisNote]] into another file via postProcess |
defaultFile, insertPattern ({{wikilink}}), defaultHeading |
| 8 | tags (TagsAction) |
price-tag-glyph |
✅ | tag list | frontmatter tags |
tags list, static |
| 9 | cssclasses (CssClassesAction) |
view |
✅ | class list | frontmatter cssclasses |
classes list, static |
| 10 | script (ScriptAction) |
code-glyph |
❌ | none (background) | anything, imperatively | CodeMirror JS editor + debug runner |
| 11 | task-management (TaskManagementAction) |
list-checks |
❌ | none (background) | rolled-over tasks → body | initialFolder, regex, rolloverHeader, prefix/suffix, key, recursive |
Outliers to know:
- BackLink overrides
postProcess(notexecute): it resolves the target file/heading and callsEditService.instance(target).insertBacklink(...).{{wikilink}}becomes[[source.basename]]. - TaskManagement scans files in
initialFolder, extracts unfinished- [ ]todos underrolloverHeader, removes them from the source files, and appends them to the new note. - Script is the imperative escape hatch — it receives the live DTOs and mutates them directly (see §6).
4. The note DTOs
ContentDTO (application/notes/model/ContentDTO.ts) — the note accumulator:
content: string(body),frontmatter: Record<string, Literal>,tags: string[].add(str)appends body;modify(key, result)replaces{{key}}in the body (thebodyzone);addFrontMatter(fm)merges frontmatter (hoisting anytagsfield intoaddTags);addTags/addTagde-dupe.
NoteDTO (application/notes/model/NoteDTO.ts) — metadata + work list:
title,targetFolder,uniquePrefixPattern.paths: Map<number, string>— chosen step-template.mdpaths, keyed by wizard position.savedActions: Map<number, FinalElement>— configured actions + their results.getFinalPath()=targetFolder/title.md;deletePos(pos)prunes paths/actions at/after a position (used by back-navigation).
5. The build pipeline — NoteBuilder.ts
build(modal, actions) branches on modal.isEditor():
- New note —
buildNewNote(): freeze the vault → compute the filename (optional date prefix) →buildNote()→ guard empty title →FileService.createFile(finalPath, body)→FrontmatterService.processTypedFrontMatter→postProcess(file). On error it deletes the half-created file and defrosts the vault. - Editor / embedded —
buildEditor():buildNote()→modal.onEditorBuild(body); if backed by a real file, alsoprocessTypedFrontMatter+postProcess.
buildNote() — for each chosen step template: read its frontmatter (addFrontMatter) and body
(add), then manageElements().
manageElements() — for each saved action element:
actionsStore.getAction(element.type).execute({ element, content, note, context }), advancing
the progress bar.
postProcess() — a second pass calling each action's postProcess(info, file) (backlinks land
here), then triggers Templater's replace-in-file-templater command after ~1 s.
Typed frontmatter write — FrontmatterService.processTypedFrontMatter writes
content.getTags() into frontmatter.tags and coerces every value to the property's native
Obsidian type via ObsidianConfig.parseType(...), using fileManager().processFrontMatter.
6. The wizard — state machine
The store
useNoteBuilderStore (application/components/noteBuilder/state/NoteBuilderState.tsx) is the one
Zustand store (created via createWithEqualityFn from zustand/traditional). Key state:
position (wizard index + Map key), section (current React element + color), header,
previousSections + previousArray (back-navigation stack), builder (the DTOs), progress-bar
fields, currentAction/currentNode, enableSkip. Mutators include setTitle,
manageNodeInfo, addAction, addBackgroundAction, addJsFile, build, reset, goPrevious,
setSectionElement.
The three selectors
Each is a <Select> wrapper: RootSelector (options from flow.rootNodes()),
ElementSelector (a node's children), ActionSelector (renders
actionsStore.getAction(type).component).
The transitions (callbacks/CallbackUtils.tsx)
nextElement—flow.get(selected)→ set current node; if it has untriggered actions →manageAction(node, 0), else →manageElement(node).manageAction(node, …, position)— ifposition >= actions.length→nextElement(descend to children); else ifaction.hasUI→ renderActionSelectorand pause for input; else →addBackgroundActionand recurse toposition+1(no UI).manageElement(node, …)— record the node's template path/folder (oraddJsFilefor a.jsstep), then look atchildrensOf(node.id): >1 → renderElementSelector(branch); 1 → auto-advancenextElement(child); 0 (leaf) → showProgressBarand callactions.build(modal).setSelectionElementAction— the "advance" primitive: optionally snapshots the current section onto the back stack, then setsposition+1and the new section.goPreviousAction— pops the back stack, restores the saved section, andbuilder.note.deletePos(previousPosition)so the DTO forgets later steps.
End-to-end sequence
RootSelector ─pick root─► callbackRootBuilder ─► initPluginConfig ─► nextElement
nextElement: flow.get(node)
├─ node has actions & !triggered ─► manageAction(node, 0)
│ ├─ hasUI ─► ActionSelector (component) ─user submits─► callbackActionBuilder
│ │ addAction(result); manageAction(pos+1)
│ └─ background ─► addBackgroundAction; manageAction(pos+1)
│ (exhausted) ─► nextElement(node.id) # descend to children
└─ else ─► manageElement(node)
manageNodeInfo (template path + folder)
childrensOf(node):
>1 ─► ElementSelector ─► callbackElementBuilder ─► nextElement(child)
=1 ─► nextElement(child) # auto
0 ─► ProgressBar ─► builder.build(modal)
buildNote → execute each action → createFile
→ processTypedFrontMatter → postProcess → open note
7. Script action & the zf API
ScriptAction.execute compiles the user's code into an AsyncFunction and calls it with
(element, content, note, context, zf), where zf = await fnsManager.getFns():
type ZettelFlowApp = {
external: { tp?: TemplaterTools; dv?: DataviewTools }; // Templater & Dataview if installed
internal: {
vault: { resolveTFolder(path), obtainFilesFrom(folder, extensions) };
user: Record<basename, defaultExport>; // every .js under jsLibraryFolderPath
};
};
The editor is CodeMirror 6 via dispatchEditor (components/core/codeView/editor/), with
javascript(), folding, bracket matching, and a ZettelFlow autocomplete driven by a
CompletionTree (note.*, content.*, context) described in NoteFns.ts / ContentFns.ts.
The settings panel also has a debug runner that executes the script against mock DTOs.
The Dynamic Selector uses a narrower script contract: called with only zf, it must
return [label, value][] tuples, which become the selectable options.
8. Adding a new action
See the step-by-step recipe in
Contributing & conventions,
and the new-action harness skill which scaffolds the 4 files and the registration for you.