apps/server/src/assets/llm/skills/backend_scripting.md
Backend scripts run in Node.js on the server. They have direct access to notes in memory and can interact with the system (files, processes).
Backend script execution is turned off by default for security (backend scripts have full server access). Until it is enabled, running a backend script — including a frontend runOnBackend() call, #run schedules, ~runOn* triggers, and custom request handlers — fails with "Backend script execution is disabled". When you create a backend script for the user, tell them it will not run until they enable it in Options → Security (equivalently [Security] backendScriptingEnabled=true in config.ini, or the TRILIUM_SECURITY_BACKEND_SCRIPTING_ENABLED=true environment variable).
A frontend script can detect this at runtime with api.isBackendScriptingEnabled() and degrade gracefully (see the frontend_scripting skill). Raw SQL access (the SQL Console note type and the /sql/execute route) is gated separately by [Security] sqlConsoleEnabled / api.isSqlConsoleEnabled().
The script body runs inside a regular (non-async) function, so top-level await is NOT allowed. Writing await directly at the top level fails with: "await is only valid in async functions and the top level bodies of modules".
To use await, wrap the awaited code in an async IIFE:
(async () => {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
api.log(JSON.stringify(data));
})();
Note that most api.* methods (e.g. api.getNote, api.searchForNotes, api.createTextNote) are synchronous and do not need await at all. Only genuinely async operations like fetch() require the wrapper above.
api global)api.startNote - note where the script execution started (the entry point of the script bundle; in C terms, the file with main()). All module notes loaded via require() share the same startNote. May be null when the execution came from the frontend via runOnBackend() (the frontend's startNote is preserved). api.log() messages are grouped under this note.api.currentNote - note containing the source code currently executing (in C terms, __FILE__). Equal to startNote except inside child module notes loaded via require(). NOT the note open in the UI.api.originEntity - entity whose event triggered this execution; undefined when the run was not event-driven (manual Execute button, note.executeScript()). For ~runOn* relations see the table under "Events and triggers"; for scheduled scripts (#run=hourly/#run=daily) it is the script note itself; for ~searchScript scripts it is the search note.Concrete examples:
| Scenario | startNote | currentNote | originEntity |
|---|---|---|---|
Execute button / note.executeScript() on "Job" | "Job" | "Job" (a child module note while its code runs) | undefined |
Scheduled "Job" (#run=backendStartup/hourly/daily) | "Job" | "Job" (or module note) | "Job" (the script note itself) |
Note "Diary" has ~runOnNoteContentChange → script "OnChange" | "OnChange" | "OnChange" (or module note) | "Diary" (the changed note; a BAttribute/BBranch for attribute/branch events) |
Custom request handler "Endpoint" (#customRequestHandler) | "Endpoint" | "Endpoint" (or module note) | undefined — the request is in api.req |
api.runOnBackend() called from frontend widget "Clock" | "Clock" (the frontend's startNote, preserved) | the frontend note whose function was serialized (the frontend's currentNote) | the frontend's originEntity (a note) or null |
Note: #customResourceProvider notes never execute a script — the note's content is served directly as the HTTP response, so there is no api context at all. Only #customRequestHandler runs code.
api.getNote(noteId) - get note by IDapi.searchForNotes(query, searchParams) - search notes (returns array)api.searchForNote(query) - search notes (returns first match)api.getNotesWithLabel(name, value?) - find notes by labelapi.getNoteWithLabel(name, value?) - find first note by labelapi.getBranch(branchId) - get branch by IDapi.getAttribute(attributeId) - get attribute by IDapi.createTextNote(parentNoteId, title, content) - create text noteapi.createDataNote(parentNoteId, title, content) - create JSON noteapi.createNewNote({ parentNoteId, title, content, type }) - create note with full optionsapi.ensureNoteIsPresentInParent(noteId, parentNoteId, prefix?) - create or reuse branchapi.ensureNoteIsAbsentFromParent(noteId, parentNoteId) - remove branch if existsapi.toggleNoteInParent(present, noteId, parentNoteId, prefix?) - toggle branchapi.getTodayNote() - get/create today's day noteapi.getDayNote(date) - get/create day note (YYYY-MM-DD)api.getWeekNote(date) - get/create week noteapi.getMonthNote(date) - get/create month note (YYYY-MM)api.getYearNote(year) - get/create year note (YYYY)api.log(message) - log to Trilium logs and UIapi.randomString(length) - generate random stringapi.escapeHtml(string) / api.unescapeHtml(string)api.getInstanceName() - get instance nameapi.getAppInfo() - get application infoapi.dayjs - date manipulationapi.xml2js - XML parserapi.htmlParser - HTML parser (node-html-parser), use api.htmlParser.parse(html) to parseapi.cheerio - DEPRECATED, use api.htmlParser insteadUse the native fetch() API for HTTP requests. Since fetch() is async and top-level await is not allowed (see "Async code" above), wrap it in an async IIFE:
(async () => {
const response = await fetch('https://api.example.com/data');
const data = await response.json();
api.log(JSON.stringify(data));
})();
Note: api.axios was removed in March 2026 following an npm supply chain attack. Use fetch() instead.
api.transactional(func) - wrap code in a database transactionapi.sql - direct SQL accessapi.sortNotes(parentNoteId, sortConfig) - sort child notesapi.runOnFrontend(script, params) - execute code on all connected frontendsapi.backupNow(backupName) - create a backupapi.exportSubtreeToZipFile(noteId, format, zipFilePath) - export subtree (format: "markdown" or "html")api.duplicateSubtree(origNoteId, newParentNoteId) - clone note and childrenAvailable on notes returned from API methods (api.getNote(), api.originEntity, etc.).
note.getContent() / note.setContent(content)note.getJsonContent() / note.setJsonContent(obj)note.getJsonContentSafely() - returns null on parse errornote.noteId, note.title, note.type, note.mimenote.dateCreated, note.dateModifiednote.isProtected, note.isArchivednote.getParentNotes() / note.getChildNotes()note.getParentBranches() / note.getChildBranches()note.hasChildren(), note.getAncestors()note.getSubtreeNoteIds() - all descendant IDsnote.hasAncestor(ancestorNoteId)note.getLabels(name?) / note.getLabelValue(name)note.getRelations(name?) / note.getRelation(name)note.hasLabel(name, value?) / note.hasRelation(name, value?)note.setLabel(name, value?) / note.removeLabel(name, value?)note.setRelation(name, targetNoteId) / note.removeRelation(name, value?)note.addLabel(name, value?, isInheritable?) / note.addRelation(name, targetNoteId, isInheritable?)note.toggleLabel(enabled, name, value?)note.save() - persist changesnote.deleteNote() - soft deletenote.cloneTo(parentNoteId) - clone to another parentnote.isJson(), note.isJavaScript(), note.isHtml(), note.isImage()note.hasStringContent() - true if not binary#run label on the script note)#run=backendStartup - run when server starts#run=hourly - run once per hour (use #runAtHour=N to specify which hours)#run=daily - run once per dayThese are defined as relations. api.originEntity contains the entity that triggered the event.
| Relation | Trigger | originEntity |
|---|---|---|
~runOnNoteCreation | note created | BNote |
~runOnChildNoteCreation | child note created under this note | BNote (child) |
~runOnNoteTitleChange | note title changed | BNote |
~runOnNoteContentChange | note content changed | BNote |
~runOnNoteChange | note metadata changed (not content) | BNote |
~runOnNoteDeletion | note deleted | BNote |
~runOnBranchCreation | branch created (clone/move) | BBranch |
~runOnBranchChange | branch updated | BBranch |
~runOnBranchDeletion | branch deleted | BBranch |
~runOnAttributeCreation | attribute created on this note | BAttribute |
~runOnAttributeChange | attribute changed/deleted on this note | BAttribute |
Relations can be inheritable — when set, they apply to all descendant notes.
A backend script with a #customRequestHandler label becomes a public REST endpoint under /custom/.... The label value is a regular expression matched against the request path (e.g. #customRequestHandler=create-note is reachable at /custom/create-note).
The label MUST have a value — a bare #customRequestHandler with no value matches nothing and the endpoint will never run. Always give it a path regex (e.g. #customRequestHandler=create-note).
The Express request and response objects are exposed as api.req and api.res — not bare req/res. Write the HTTP response by calling methods on api.res.
const { req, res } = api; // destructure from api — api.req / api.res, never global req/res
const { secret, title, content } = req.body;
if (req.method === "POST" && secret === "secret-password") {
const targetParentNoteId = api.currentNote.getRelationValue("targetNote");
const { note } = api.createTextNote(targetParentNoteId, title, content);
res.status(201).json(note.getPojo());
} else {
res.sendStatus(400);
}
api.pathParams (e.g. #customRequestHandler=notes/([0-9]+) → api.pathParams[0]).api.req.query.noteId.// Attach via ~runOnAttributeChange relation
const attr = api.originEntity;
if (attr.name !== "mycategory") return;
const note = api.getNote(attr.noteId);
if (attr.value === "Health") {
note.setLabel("color", "green");
} else {
note.removeLabel("color");
}
// Attach #run=daily label
const today = api.getTodayNote();
const tasks = api.searchForNotes('#task #!completed');
let summary = "## Open Tasks\n";
for (const task of tasks) {
summary += `- ${task.title}\n`;
}
api.createTextNote(today.noteId, "Daily Summary", summary);
Child notes of a script act as modules. Export with module.exports = ... and import via function parameters matching the child note title, or use require('noteName').