Skip to content

User Scripts

A user script is a piece of JavaScript you write once and run from a Macro. It can prompt for input, read and write notes, call Obsidian’s own APIs, talk to other plugins, and pass values into the rest of the macro - the escape hatch for anything QuickAdd’s built-in choices don’t cover. This page is the full reference: how a script is shaped, how to give it settings, what the params API offers, and a shelf of copy-paste recipes.

You don’t need to be a professional developer, but you should be comfortable reading and lightly editing JavaScript.

A user script can live in either of two places inside your vault:

  • a standalone .js file, or
  • a note (.md) containing a ```js (or ```javascript) code block.

The note form is handy on mobile, where Obsidian can’t open .js files at all.

In the Macro Builder, Browse opens QuickAdd’s picker of discovered scripts (both .js files and notes that contain a code block); it is not a native file picker. If you add a script manually, type a .js script’s basename - for scripts/my-script.js, enter my-script - or, for a note, type its vault path (e.g. Scripts/my-script.md). For a specific export, append a member expression such as my-script::start (or Scripts/my-script.md::start).

Write your script in a ```js code block inside any note; QuickAdd runs the first ```js (or ```javascript) block in the note and ignores all prose and other code blocks. The block is a CommonJS module exactly like a .js file - it must assign module.exports / exports.default (a top-level return the way inline scripts work will not export anything):

# My script
Notes about what this does - ignored by QuickAdd.
```js
module.exports = async (params) => {
// Your code here
};
```

If the note has no ```js block, QuickAdd shows a notice and skips the script.

Every user script must export a module with at least an entry point function:

module.exports = async (params) => {
// Your code here
};

Or with the object syntax:

module.exports = {
entry: start,
settings: {
name: "Script Name",
author: "Your Name",
options: {
// Define configurable options here
}
}
};
async function start(params, settings) {
// Your code here
}

Use the plain function form for a quick script, and the object form when you want configurable settings in the QuickAdd UI.

The script is called with up to two arguments: params (always) and settings (only with the object form above).

params carries the QuickAdd API and the Obsidian context:

{
app: App, // Obsidian app instance - see https://docs.obsidian.md/Reference/TypeScript+API/App
quickAddApi: QuickAddApi, // QuickAdd API methods (documented below)
variables: {}, // Variables object for sharing data between scripts and templates
obsidian: obsidian, // Obsidian module with all classes and utilities
abort: (message) => never // Abort macro execution with optional message
}

The app object provides access to the entire Obsidian API, including:

settings holds the user-configured values for your script’s options. It’s only passed when you use the object structure with a settings block.

User scripts can define configurable options that users set through the QuickAdd UI. This makes your scripts flexible and reusable - a folder path, an API key, or an on/off toggle becomes a form field instead of a line you have to edit.

For regular string values, paths, names, etc.

options: {
"Project": {
type: "text", // or "input"
defaultValue: "",
placeholder: "Project name",
description: "Default project" // Optional: help text
}
}

For API keys, access tokens, and other sensitive values. Secrets are stored in Obsidian’s SecretStorage and QuickAdd stores only a reference in data.json.

options: {
"API Key": {
type: "secret",
id: "api-key", // Optional stable storage id
placeholder: "Paste API key",
description: "Your API key"
}
}

type: "text" / type: "input" with secret: true is still supported for older scripts and is treated as a secret setting.

Secret values are local to the Obsidian app profile. They are not included in QuickAdd package exports and are not synced through the plugin’s data.json; users must enter them on each device where the script runs.

The optional id controls the SecretStorage reference used for the setting. If omitted, QuickAdd uses the option name. Set id when you want to rename the visible setting label later without creating a new saved secret.

For boolean on/off settings.

options: {
"Enable Feature": {
type: "toggle", // or "checkbox"
defaultValue: false,
description: "Enable this feature"
}
}

For choosing from predefined options.

options: {
"Output Format": {
type: "dropdown", // or "select"
defaultValue: "markdown",
options: ["markdown", "plain", "html"],
description: "Choose output format"
}
}

For template strings with QuickAdd format syntax support.

options: {
"File Name Template": {
type: "format",
defaultValue: "{{DATE}} - {{VALUE:title}}",
placeholder: "Enter template",
description: "Template for file names"
}
}

Here’s a comprehensive example showing all option types, and reading them back inside start:

module.exports = {
entry: start,
settings: {
name: "Advanced Script",
author: "Your Name",
options: {
"API Key": {
type: "secret",
placeholder: "sk-...",
description: "OpenAI API key"
},
"Model": {
type: "dropdown",
defaultValue: "gpt-3.5-turbo",
options: ["gpt-3.5-turbo", "gpt-4", "gpt-4-turbo"],
description: "AI model to use"
},
"Include Metadata": {
type: "toggle",
defaultValue: true,
description: "Include file metadata in output"
},
"Output Template": {
type: "format",
defaultValue: "## {{DATE:YYYY-MM-DD}}\n{{VALUE:content}}",
placeholder: "Template for output",
description: "Format for the output"
},
"Max Results": {
type: "text",
defaultValue: "10",
placeholder: "Number",
description: "Maximum number of results"
}
}
}
};
async function start(params, settings) {
const { quickAddApi, app, variables } = params;
// Access settings
const apiKey = settings["API Key"];
const model = settings["Model"];
const includeMetadata = settings["Include Metadata"];
const outputTemplate = settings["Output Template"];
const maxResults = parseInt(settings["Max Results"]);
// Validate inputs
if (!apiKey) {
new Notice("Please configure your API key in the script settings");
throw new Error("API key not configured");
}
// Use QuickAdd API
const query = await quickAddApi.inputPrompt("Enter your search query:");
if (!query) return;
// Your logic here...
console.log(`Using model: ${model}`);
console.log(`Max results: ${maxResults}`);
if (includeMetadata) {
// Include metadata logic
}
// Set variables for use in templates
variables.model = model;
variables.query = query;
variables.resultCount = maxResults;
}

User scripts have full access to the QuickAdd API through params.quickAddApi. For complete API documentation, see the QuickAdd API Reference. The sections below cover the calls you’ll reach for most.

// Text input
const text = await quickAddApi.inputPrompt("Enter text:");
// Wide text input (multi-line)
const longText = await quickAddApi.wideInputPrompt("Enter description:");
// Yes/No confirmation
const confirmed = await quickAddApi.yesNoPrompt("Are you sure?");
// Suggester (dropdown selection)
const choice = await quickAddApi.suggester(
["Option 1", "Option 2", "Option 3"],
["value1", "value2", "value3"]
);
// Checkbox selection
const selected = await quickAddApi.checkboxPrompt(
["Item 1", "Item 2", "Item 3"],
["Item 1"] // Pre-selected items
);

Set variables that can be used in subsequent template operations:

// Set variables
params.variables.myVariable = "value";
params.variables.timestamp = new Date().toISOString();
params.variables.results = arrayOfResults;
// Variables are accessible in templates as {{VALUE:myVariable}}

Format strings with QuickAdd syntax:

const formatted = await quickAddApi.format(
"Today is {{DATE}} and the title is {{VALUE:title}}",
{ title: "My Document" }
);

Trigger other QuickAdd choices programmatically:

await quickAddApi.executeChoice("My Other Choice", {
customVariable: "value"
});

A script can talk to other Obsidian plugins through app.plugins.plugins. Check that the plugin is there before using it:

module.exports = async (params) => {
const { app } = params;
// Access Templater
const templater = app.plugins.plugins["templater-obsidian"];
if (templater) {
// Use Templater API
}
// Access MetaEdit
const metaedit = app.plugins.plugins["metaedit"];
if (metaedit) {
const { update } = metaedit.api;
await update("property", "value", "path/to/file.md");
}
};

The plugin must be installed and enabled in the vault, so check for it and fail with a clear message when it’s missing.

You can intentionally stop a macro using params.abort(). This is useful for validation or conditional execution:

module.exports = async (params) => {
const { abort, quickAddApi } = params;
// Get user input
const projectName = await quickAddApi.inputPrompt("Project name:");
// Validate input
if (!projectName || projectName.length < 3) {
abort("Project name must be at least 3 characters");
// Execution stops here - remaining macro commands won't run
}
// Continue with valid input
console.log(`Creating project: ${projectName}`);
};

When to use params.abort():

  • Input validation failures
  • Missing required configuration
  • You want to provide a custom message after catching a MacroAbortError
  • Prerequisites not met (e.g., required plugin not installed)

Prompt cancellations already throw MacroAbortError and halt macros automatically, so only call abort() in those scenarios if you need to surface a custom message or you’re stopping for a non-prompt reason.

What happens when you call abort():

  • Macro execution stops immediately
  • A message is logged: “Macro execution aborted: [your message]”
  • Remaining commands in the macro are skipped
  • No error is thrown to the user

QuickAdd API methods that can be cancelled:

  • inputPrompt()
  • wideInputPrompt()
  • yesNoPrompt()
  • suggester()
  • checkboxPrompt()

Each of these now rejects with MacroAbortError("Input cancelled by user") when the user presses Escape or closes the dialog. If you do nothing, the macro will automatically stop (matching user expectations). If you want to handle cancellation in your script, wrap the call in try/catch and intercept the error before it reaches the macro engine.

try {
const name = await quickAddApi.inputPrompt("Your name:");
} catch (error) {
if (error?.name === "MacroAbortError") {
// Optional custom handling (e.g., cleanup) before the macro aborts
return;
}
throw error; // real errors should still bubble up
}

Important: Because cancellations now throw, you should only call abort() yourself when you want to provide a custom message or stop execution for a non-prompt reason.

module.exports = async (params) => {
const { quickAddApi, abort } = params;
let name;
try {
name = await quickAddApi.inputPrompt("Your name:");
} catch (error) {
if (error?.name === "MacroAbortError") {
abort("Name is required");
return;
}
throw error;
}
console.log(`Processing: ${name}`);
};

Always include proper error handling for unexpected errors:

async function start(params, settings) {
try {
// Your code here
const result = await riskyOperation();
if (!result) {
new Notice("Operation failed", 5000);
throw new Error("Failed to complete operation");
}
} catch (error) {
console.error("Script error:", error);
new Notice(`Error: ${error.message}`, 5000);
throw error; // Re-throw to stop macro execution
}
}

Error behavior:

  • Unhandled errors in scripts automatically stop the macro
  • Original error stack traces are preserved for debugging
  • Errors are logged to the console for troubleshooting
  1. Validate Settings: Always check that required settings are configured before using them
  2. User Feedback: Use new Notice() to provide feedback to users
  3. Error Messages: Provide clear, actionable error messages
  4. Default Values: Always provide sensible default values for options
  5. Documentation: Add descriptions to all options to help users understand their purpose
  6. Variable Names: Use clear, descriptive names for variables you set
  7. Async/Await: Always use async/await for asynchronous operations
  8. Console Logging: Use console.log() for debugging, but remove or minimize in production

Need the same helper functions or constants in several scripts? Put them in one shared .js module and require() it from each script. User scripts run as CommonJS modules and receive a require function (backed by Obsidian’s Electron/Node require), so you can load any module on disk by its absolute path.

Save a normal .js file in your vault. (It must be a real .js file - require loads from disk and cannot read a script written in a ```js note code block.) Export your helpers with module.exports:

scripts/shared-utils.js
module.exports = {
formatNumber: (n) => new Intl.NumberFormat("en-US").format(n),
CURRENCY: "USD",
};

The module must already exist on disk when the requiring script runs.

Build the absolute path at runtime from the vault’s base path, then require the module. Don’t paste a literal absolute path - the vault lives at a different location on every machine and account, so a hardcoded path breaks the moment the vault is synced or opened elsewhere. Relative paths don’t work either (the script has no __dirname), so always resolve against getBasePath():

scripts/my-macro-script.js
module.exports = async (params) => {
const { app, obsidian } = params;
// require() is desktop-only - fail clearly on mobile.
if (!(app.vault.adapter instanceof obsidian.FileSystemAdapter)) {
throw new Error("This script shares helpers via require(), which only works in the desktop app.");
}
const path = require("path");
// getBasePath() is the supported method; older scripts may use the
// equivalent app.vault.adapter.basePath.
const utils = require(path.join(app.vault.adapter.getBasePath(), "scripts", "shared-utils.js"));
new obsidian.Notice(`${utils.formatNumber(1234567)} ${utils.CURRENCY}`); // "1,234,567 USD"
};

Because require is desktop-only, on mobile (or for a portable vault) share code another way:

  • Inline the helper in each script, or
  • Chain scripts in a macro and pass data (not functions) through params.variables between steps.

Export an object with multiple functions that users can choose from. The object can also carry plain values (a default or shared constant) alongside the functions:

module.exports = {
"Create Note": createNote,
"Update Note": updateNote,
"Delete Note": deleteNote
};
async function createNote(params) {
// Implementation
}
async function updateNote(params) {
// Implementation
}
async function deleteNote(params) {
// Implementation
}

Scripts can return values that become available as macro output:

async function start(params, settings) {
const result = await processData();
// Return value becomes available as macro output
return result;
}
async function start(params, settings) {
const { app, obsidian } = params;
// Get all markdown files
const files = app.vault.getMarkdownFiles();
// Get a specific file
const file = app.vault.getAbstractFileByPath("path/to/file.md");
if (file instanceof obsidian.TFile) {
// Read file content
const content = await app.vault.read(file);
// Get file metadata
const metadata = app.metadataCache.getFileCache(file);
const frontmatter = metadata?.frontmatter;
const links = metadata?.links || [];
const tags = metadata?.tags || [];
// Process content
const modified = content.replace(/old/g, "new");
// Save changes
await app.vault.modify(file, modified);
}
// Create new file
const newFile = await app.vault.create(
"folder/subfolder/new-note.md",
"# Title\n\nContent here"
);
// Rename file
await app.fileManager.renameFile(
newFile,
"folder/subfolder/renamed-note.md"
);
// Delete file
await app.vault.delete(newFile);
// Copy file
await app.vault.copy(
file,
"path/to/copy.md"
);
// Get folder
const folder = app.vault.getAbstractFileByPath("folder/subfolder");
if (folder instanceof obsidian.TFolder) {
// List folder contents
const children = folder.children;
// Create folder if it doesn't exist
const path = "new/folder/structure";
if (!app.vault.getAbstractFileByPath(path)) {
await app.vault.createFolder(path);
}
}
}
async function start(params, settings) {
const { app, obsidian } = params;
// Get active file
const activeFile = app.workspace.getActiveFile();
if (!activeFile) {
new obsidian.Notice("No active file");
return;
}
// Get active editor
const activeView = app.workspace.getActiveViewOfType(obsidian.MarkdownView);
if (activeView) {
const editor = activeView.editor;
// Get selected text
const selection = editor.getSelection();
// Get current line
const cursor = editor.getCursor();
const line = editor.getLine(cursor.line);
// Replace selection
editor.replaceSelection("New text");
// Insert at cursor
editor.replaceRange(
"Inserted text",
cursor
);
// Get entire document
const fullText = editor.getValue();
// Replace entire document
editor.setValue("Completely new content");
}
}
async function start(params, settings) {
const { app, obsidian } = params;
const file = app.workspace.getActiveFile();
if (!file) return;
// Get frontmatter
const cache = app.metadataCache.getFileCache(file);
const frontmatter = cache?.frontmatter || {};
// Update frontmatter
await app.fileManager.processFrontMatter(file, (fm) => {
fm.tags = fm.tags || [];
fm.tags.push("processed");
fm.date = new Date().toISOString();
fm.status = "completed";
delete fm.oldField; // Remove a field
});
// Get all files with specific frontmatter
const filesWithTag = app.vault.getMarkdownFiles().filter(f => {
const meta = app.metadataCache.getFileCache(f);
return meta?.frontmatter?.tags?.includes("important");
});
// Get backlinks
const backlinks = app.metadataCache.getBacklinksForFile(file);
// Get outgoing links
const links = cache?.links || [];
const embeds = cache?.embeds || [];
}
async function start(params, settings) {
const { app, obsidian } = params;
// Open file in active pane
const file = app.vault.getAbstractFileByPath("path/to/note.md");
if (file instanceof obsidian.TFile) {
await app.workspace.getLeaf().openFile(file);
}
// Open in new pane
await app.workspace.getLeaf('split').openFile(file);
// Open in new tab
await app.workspace.getLeaf('tab').openFile(file);
// Open in new window
await app.workspace.getLeaf('window').openFile(file);
// Navigate to specific heading
await app.workspace.openLinkText(
"note#heading",
"", // source path
true // new leaf
);
// Create and open a daily note
const { createDailyNote } = app.plugins.plugins["daily-notes"].instance;
const dailyNote = await createDailyNote(moment());
await app.workspace.getLeaf().openFile(dailyNote);
}
async function processAllNotesInFolder(params, settings) {
const { app, obsidian, quickAddApi } = params;
const folderPath = settings["Folder Path"] || "Notes";
const folder = app.vault.getAbstractFileByPath(folderPath);
if (!(folder instanceof obsidian.TFolder)) {
throw new Error(`Folder not found: ${folderPath}`);
}
let processed = 0;
const errors = [];
// Process each markdown file in folder
for (const file of folder.children) {
if (file instanceof obsidian.TFile && file.extension === "md") {
try {
const content = await app.vault.read(file);
// Your processing logic here
const modified = content + "\n\n---\nProcessed: " + new Date().toISOString();
await app.vault.modify(file, modified);
processed++;
} catch (error) {
errors.push(`${file.path}: ${error.message}`);
}
}
}
// Report results
new obsidian.Notice(`Processed ${processed} files`);
if (errors.length > 0) {
await quickAddApi.infoDialog("Errors", errors);
}
return { processed, errors };
}
async function createNoteFromTemplate(params, settings) {
const { app, quickAddApi, variables } = params;
// Get template
const templatePath = settings["Template Path"];
const templateFile = app.vault.getAbstractFileByPath(templatePath);
if (!templateFile) {
throw new Error(`Template not found: ${templatePath}`);
}
// Read template content
const template = await app.vault.read(templateFile);
// Get user input
const title = await quickAddApi.inputPrompt("Note title:");
const tags = await quickAddApi.inputPrompt("Tags (comma-separated):");
// Format the template
const formatted = await quickAddApi.format(template, {
title: title,
tags: tags.split(",").map(t => `#${t.trim()}`).join(" "),
date: new Date().toISOString()
});
// Create the note
const fileName = `${title.replace(/[^\w\s]/gi, '')}.md`;
const filePath = `${settings["Output Folder"]}/${fileName}`;
await app.vault.create(filePath, formatted);
// Open the new note
const newFile = app.vault.getAbstractFileByPath(filePath);
await app.workspace.getLeaf().openFile(newFile);
return filePath;
}
async function bulkTagOperations(params, settings) {
const { app, quickAddApi, obsidian } = params;
const operation = await quickAddApi.suggester(
["Add tag", "Remove tag", "Replace tag"],
["add", "remove", "replace"]
);
const tag = await quickAddApi.inputPrompt("Tag name (without #):");
let newTag;
if (operation === "replace") {
newTag = await quickAddApi.inputPrompt("Replace with tag:");
}
const files = app.vault.getMarkdownFiles();
let modified = 0;
for (const file of files) {
await app.fileManager.processFrontMatter(file, (fm) => {
fm.tags = fm.tags || [];
if (operation === "add" && !fm.tags.includes(tag)) {
fm.tags.push(tag);
modified++;
} else if (operation === "remove") {
const index = fm.tags.indexOf(tag);
if (index > -1) {
fm.tags.splice(index, 1);
modified++;
}
} else if (operation === "replace") {
const index = fm.tags.indexOf(tag);
if (index > -1) {
fm.tags[index] = newTag;
modified++;
}
}
});
}
new obsidian.Notice(`Modified ${modified} files`);
return modified;
}
async function searchAndReplace(params, settings) {
const { app, quickAddApi, obsidian } = params;
const searchTerm = await quickAddApi.inputPrompt("Search for:");
const replaceTerm = await quickAddApi.inputPrompt("Replace with:");
const confirm = await quickAddApi.yesNoPrompt(
"Confirm",
`Replace all occurrences of "${searchTerm}" with "${replaceTerm}"?`
);
if (!confirm) return;
const files = app.vault.getMarkdownFiles();
const results = [];
for (const file of files) {
const content = await app.vault.read(file);
if (content.includes(searchTerm)) {
const newContent = content.replaceAll(searchTerm, replaceTerm);
await app.vault.modify(file, newContent);
const count = (content.match(new RegExp(searchTerm, 'g')) || []).length;
results.push(`${file.path}: ${count} replacements`);
}
}
if (results.length > 0) {
await quickAddApi.infoDialog("Replacements Made", results);
} else {
new obsidian.Notice("No matches found");
}
return results;
}
async function enhanceDailyNote(params, settings) {
const { app, obsidian } = params;
// Get or create today's daily note
const { moment } = window;
const dailyNotes = app.plugins.plugins["daily-notes"];
if (!dailyNotes) {
throw new Error("Daily Notes plugin not enabled");
}
const { createDailyNote, getDailyNote } = dailyNotes.instance;
const date = moment();
let dailyNote = getDailyNote(date, dailyNotes.instance.options);
if (!dailyNote) {
dailyNote = await createDailyNote(date);
}
// Add content to daily note
const content = await app.vault.read(dailyNote);
// Add weather (example - would need actual API)
const weather = "☀️ Sunny, 22°C";
// Add tasks from yesterday
const yesterday = moment().subtract(1, 'day');
const yesterdayNote = getDailyNote(yesterday, dailyNotes.instance.options);
let unfinishedTasks = "";
if (yesterdayNote) {
const yesterdayContent = await app.vault.read(yesterdayNote);
const taskRegex = /- \[ \] .+/g;
const tasks = yesterdayContent.match(taskRegex);
if (tasks) {
unfinishedTasks = "\n## Carried Over Tasks\n" + tasks.join("\n");
}
}
const enhanced = content + `
## Weather
${weather}
${unfinishedTasks}
## Notes
-
## Gratitude
-
`;
await app.vault.modify(dailyNote, enhanced);
await app.workspace.getLeaf().openFile(dailyNote);
return dailyNote.path;
}
  1. Use Console Logging Strategically

    console.log("Script started", { settings, params });
    console.group("Processing files");
    files.forEach(f => console.log(f.path));
    console.groupEnd();
  2. Developer Console Access

    • Windows/Linux: Ctrl + Shift + I
    • Mac: Cmd + Option + I
    • Filter console by typing “QuickAdd” to see only relevant logs
  3. Error Boundaries

    try {
    // Risky operation
    await app.vault.modify(file, content);
    } catch (error) {
    console.error("Failed to modify file:", error);
    new Notice(`Error: ${error.message}`);
    // Continue or throw depending on severity
    }
  4. Performance Monitoring

    console.time("Processing files");
    // ... your code
    console.timeEnd("Processing files");
  5. Debugging State

    // Use debugger statement to pause execution
    debugger; // Execution will pause here if DevTools is open
    // Inspect variables at specific points
    console.table(variables); // Great for objects/arrays

For complete working examples, see:

Script not loading:

  • Check the file path in your macro configuration
  • Ensure the script exports a valid module
  • Check console for syntax errors

Settings not appearing:

  • Verify the settings object structure
  • Ensure option names are unique
  • Restart Obsidian after making changes to the settings structure

Variables not available in templates:

  • Make sure you’re setting them on params.variables
  • Read them with {{VALUE:name}}, not {{name}}
  • Check that the script completes successfully
  • See Variables and data flow for how script variables move into later Template and Capture steps

API methods returning undefined:

  • Ensure you’re using await with async methods
  • Check that QuickAdd plugin is enabled
  • Verify you’re accessing the API correctly through params.quickAddApi