Skip to content

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 — the CustomZettelAction subclass (registration + execute/postProcess).
  • *Component.tsx — the build-time React UI (skipped for background actions).
  • *Settings.ts(x) — the design-time config UI (Obsidian Setting rows 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.
  • bodycontent.modify(key, value) → replaces {{key}} placeholders in the merged step-template body.
  • contextcontext[key] = valuenot 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 (not execute): it resolves the target file/heading and calls EditService.instance(target).insertBacklink(...). {{wikilink}} becomes [[source.basename]].
  • TaskManagement scans files in initialFolder, extracts unfinished - [ ] todos under rolloverHeader, 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 (the body zone); addFrontMatter(fm) merges frontmatter (hoisting any tags field into addTags); addTags/addTag de-dupe.

NoteDTO (application/notes/model/NoteDTO.ts) — metadata + work list:

  • title, targetFolder, uniquePrefixPattern.
  • paths: Map<number, string> — chosen step-template .md paths, 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 notebuildNewNote(): freeze the vault → compute the filename (optional date prefix) → buildNote() → guard empty title → FileService.createFile(finalPath, body)FrontmatterService.processTypedFrontMatterpostProcess(file). On error it deletes the half-created file and defrosts the vault.
  • Editor / embeddedbuildEditor(): buildNote()modal.onEditorBuild(body); if backed by a real file, also processTypedFrontMatter + 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 writeFrontmatterService.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)

  • nextElementflow.get(selected) → set current node; if it has untriggered actions → manageAction(node, 0), else → manageElement(node).
  • manageAction(node, …, position) — if position >= actions.lengthnextElement (descend to children); else if action.hasUI → render ActionSelector and pause for input; else → addBackgroundAction and recurse to position+1 (no UI).
  • manageElement(node, …) — record the node's template path/folder (or addJsFile for a .js step), then look at childrensOf(node.id): >1 → render ElementSelector (branch); 1 → auto-advance nextElement(child); 0 (leaf) → show ProgressBar and call actions.build(modal).
  • setSelectionElementAction — the "advance" primitive: optionally snapshots the current section onto the back stack, then sets position+1 and the new section.
  • goPreviousAction — pops the back stack, restores the saved section, and builder.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.