apps/server/src/assets/llm/skills/frontend_scripting.md
Frontend scripts run in the browser. They can manipulate the UI, navigate notes, show dialogs, and create custom widgets.
IMPORTANT: Always prefer Preact JSX widgets over legacy jQuery widgets. Use JSX code notes with import/export syntax.
CRITICAL: In JSX notes, always use top-level import statements (e.g. import { useState } from "trilium:preact"). NEVER use dynamic await import() for Preact imports — this will break hooks and components. Dynamic imports are not needed because JSX notes natively support ES module import/export syntax.
#widget label for widgets, or #run=frontendStartup for auto-run scripts.#run=mobileStartup instead.NOTE: #widget, #run and ~renderNote enable code execution, so set_attribute refuses them as dangerous. Create the code note yourself, then ask the user to add the activating attribute, telling them exactly which note to open and what to type into its attribute area.
| Type | Language | Required attribute |
|---|---|---|
| Custom widget | JSX (preferred) | #widget |
| Regular script | JavaScript (Trilium frontend) | #run=frontendStartup (optional) |
| Render note | JSX | None (used via ~renderNote relation) |
import { defineWidget } from "trilium:preact";
import { useState } from "trilium:preact";
export default defineWidget({
parent: "center-pane",
position: 10,
render: () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>
Clicked {count} times
</button>
</div>
);
}
});
import { defineWidget, useNoteContext, useNoteProperty } from "trilium:preact";
export default defineWidget({
parent: "note-detail-pane",
position: 10,
render: () => {
const { note } = useNoteContext();
const title = useNoteProperty(note, "title");
return <span>Current note: {title}</span>;
}
});
import { defineWidget, RightPanelWidget, useState, useEffect } from "trilium:preact";
export default defineWidget({
parent: "right-pane",
position: 1,
render() {
const [time, setTime] = useState();
useEffect(() => {
const interval = setInterval(() => {
setTime(new Date().toLocaleString());
}, 1000);
return () => clearInterval(interval);
});
return (
<RightPanelWidget id="my-clock" title="Clock">
<p>The time is: {time}</p>
</RightPanelWidget>
);
}
});
parent values)| Value | Description | Notes |
|---|---|---|
left-pane | Alongside the note tree | |
center-pane | Content area, spanning all splits | |
note-detail-pane | Inside a note, split-aware | Use useNoteContext() hook |
right-pane | Right sidebar section | Wrap in <RightPanelWidget> |
// API methods
import { showMessage, showError, getNote, searchForNotes, activateNote,
runOnBackend, runAsyncOnBackendWithManualTransactionHandling,
getActiveContextNote } from "trilium:api";
// Hooks and components
import { defineWidget, defineLauncherWidget,
useState, useEffect, useCallback, useMemo, useRef,
useNoteContext, useActiveNoteContext, useNoteProperty,
RightPanelWidget } from "trilium:preact";
// Built-in UI components
import { ActionButton, Button, LinkButton, Modal,
NoteAutocomplete, FormTextBox, FormToggle, FormCheckbox,
FormDropdownList, FormGroup, FormText, FormTextArea,
Icon, LoadingSpinner, Slider, Collapsible } from "trilium:preact";
useNoteContext() - returns { note } for the current note context (use in note-detail-pane)useActiveNoteContext() - returns { note, noteId } for the active note (works from any widget location)useNoteProperty(note, propName) - reactively watches a note property (e.g. "title", "type")For rendering custom content inside a note:
~renderNote relation on the render note pointing to the child JSX note (you cannot set this dangerous attribute yourself).IMPORTANT: Always create the JSX code note as a child of the render note, not as a sibling or at the root. This keeps them organized together.
IMPORTANT: To reference "this widget's note" from inside a render note component, use originEntity — for render notes it is the note being rendered (the one carrying the ~renderNote relation). Do NOT use getActiveContextNote() for this: it returns the note the user currently has open in the UI, which is the render note's host (e.g. a dashboard or a book view), not the render note itself.
import { originEntity } from "trilium:api";
// originEntity = the render note hosting this component
Stateless example:
export default function MyRenderNote() {
return (
<>
<h1>Custom rendered content</h1>
<p>This appears inside the note.</p>
</>
);
}
Stateful example — hooks MUST be imported from "trilium:preact":
import { useState } from "trilium:preact";
export default function CelsiusToFahrenheit() {
const [celsius, setCelsius] = useState("");
const fahrenheit = celsius === "" ? "" : (Number(celsius) * 9 / 5 + 32).toFixed(2);
return (
<div>
<input
type="number"
value={celsius}
onInput={e => setCelsius(e.currentTarget.value)}
/>
<span>{fahrenheit} °F</span>
</div>
);
}
LLMs often invent syntax that does not exist in Trilium. Avoid these:
trilium.preact.useState(...) — trilium is not a global object; there is no trilium.preact namespacewindow.trilium.preact.useState(...) — same; no such globalReact.useState(...) / import React from "react" — Trilium uses Preact, NOT Reactconst { useState } = await import("trilium:preact") — dynamic imports break hooks; always use top-level importconst { useState } = require("trilium:preact") — JSX notes are ES modules, not CommonJSThe ONLY correct way to use hooks or components is a top-level ES import:
import { useState, useEffect } from "trilium:preact";
import { showMessage } from "trilium:api";
In JSX, use import { method } from "trilium:api". In JavaScript (Trilium frontend), use the api global.
startNote - note where the script execution started (the entry point of the script bundle). All module notes loaded by the same execution share one startNote; log() messages are grouped under it.currentNote - note containing the source code currently executing. Equal to startNote except inside child module notes. NOT the note open in the UI — that is getActiveContextNote().originEntity - note whose event triggered this execution, or null. Usually null (scripts started by the user or the UI). It is set for render notes — the note being rendered, i.e. the one carrying the ~renderNote relation — and for api.runOnFrontend() calls from the backend (the backend's originEntity, if it was a note).Concrete examples:
| Scenario | startNote | currentNote | originEntity |
|---|---|---|---|
Widget script "Clock" (#widget) | "Clock" | "Clock" (a child module note while its code runs) | null |
Startup script (#run=frontendStartup) or manual Execute on "Setup" | "Setup" | "Setup" (or module note) | null |
Render note "Stats" with ~renderNote → JSX note "StatsComponent" | "StatsComponent" | "StatsComponent" (or module note) | "Stats" (the render note) |
api.runOnFrontend() called from a backend script | the backend execution's currentNote | same | the backend's originEntity if it was a note, else null |
activateNote(notePath) - navigate to a noteactivateNewNote(notePath) - navigate and wait for syncopenTabWithNote(notePath, activate?) - open in new tabopenSplitWithNote(notePath, activate?) - open in new splitgetActiveContextNote() - get currently active notegetActiveContextNotePath() - get path of active notesetHoistedNoteId(noteId) - hoist/unhoist notegetNote(noteId) - get note by IDgetNotes(noteIds) - bulk fetch notessearchForNotes(searchString) - search with full query syntaxsearchForNote(searchString) - search returning first resultcreateNote(parentNotePath, opts?) - create a note under parentNotePath (a noteId or path) entirely on the frontend — no backend scripting required. opts accepts { title, content, type, mime, templateNoteId, isProtected, activate, focus, target, attributes } and returns { note, branch } from the cache. The new note is activated with its title focused by default; pass { activate: false } to create it silently. Prefer this over runOnBackend(() => api.createTextNote(...)) — the backend variant needs backend scripting, which is off by default.const { note } = await createNote(parentNoteId, { title: "New task", type: "text" });
getTodayNote() - get/create today's notegetDayNote(date) / getWeekNote(date) / getMonthNote(month) / getYearNote(year)getActiveContextTextEditor() - get CKEditor instancegetActiveContextCodeEditor() - get CodeMirror instanceaddTextToActiveContextEditor(text) - insert text into active editorshowMessage(msg) - info toastshowError(msg) - error toastshowConfirmDialog(msg) - confirm dialog (returns boolean)showPromptDialog(msg) - prompt dialog (returns user input)Backend script execution is disabled by default (a security setting, since backend scripts have full server access). When it is off, runOnBackend and runAsyncOnBackendWithManualTransactionHandling reject with "Backend script execution is disabled". Prefer doing the work on the frontend whenever possible (most api.* note/search/date operations are available on the frontend too). When the backend is genuinely required, guard the call with the check below and tell the user to enable it in Options → Security.
isBackendScriptingEnabled() - returns whether backend script execution is enabled (the [Security] backendScriptingEnabled config toggle). Check this before calling runOnBackend / runAsyncOnBackendWithManualTransactionHandling so the script can degrade gracefully instead of throwing.isSqlConsoleEnabled() - returns whether the SQL console is enabled (the [Security] sqlConsoleEnabled config toggle). Check this before running raw SQL on the backend (api.sql.*).runOnBackend(func, params) - execute a function on the backend. The function MUST be synchronous — do NOT pass an async function. runOnBackend wraps the call in a SQL transaction, which does not support async; passing an async function triggers the warning "You're passing an async function to api.runOnBackend() which will likely not work as you intended" and the transaction will not behave correctly.runAsyncOnBackendWithManualTransactionHandling(func, params) - use this instead when the backend function genuinely needs to be async (e.g. it awaits a fetch()). Automatic transaction management is disabled, so wrap any DB writes in api.transactional(...) yourself inside the function.// Guard backend work so a disabled instance shows a friendly message instead of an error toast
import { isBackendScriptingEnabled, runOnBackend, showError } from "trilium:api";
if (!isBackendScriptingEnabled()) {
showError("This feature needs backend scripting — enable it in Options → Security.");
return;
}
const title = await runOnBackend((noteId) => api.getNote(noteId).title, [noteId]);
// ✅ Synchronous backend work — use runOnBackend
const title = await runOnBackend((noteId) => {
return api.getNote(noteId).title;
}, [noteId]);
// ✅ Async backend work (e.g. fetch) — use the manual-transaction variant
const data = await runAsyncOnBackendWithManualTransactionHandling(async (url) => {
const response = await fetch(url);
return await response.json();
}, [url]);
triggerCommand(name, data) - trigger a commandbindGlobalShortcut(shortcut, handler, namespace?) - add keyboard shortcutformatDateISO(date) - format as YYYY-MM-DDrandomString(length) - generate random stringdayjs - day.js librarylog(message) - log to script log paneAvailable via getNote(), getActiveContextNote(), useNoteContext(), etc.
note.noteId, note.title, note.type, note.mimenote.isProtected, note.isArchivednote.getContent() - get note contentnote.getJsonContent() - parse content as JSONnote.getParentNotes() / note.getChildNotes()note.hasChildren(), note.getSubtreeNoteIds()note.getAttributes(type?, name?) - all attributes (including inherited)note.getOwnedAttributes(type?, name?) - only owned attributesnote.hasAttribute(type, name) - check for attributeDesktop-only functionality is exposed on window.electronApi by the preload script. It is not part of the api global. The global is undefined in the browser/server build and in the standalone (WASM) build, so always guard usage.
if (window.electronApi) {
window.electronApi.window.setZoomFactor(1.2);
}
Use optional chaining for one-off calls: window.electronApi?.window.minimizeWindow().
| Group | What it covers |
|---|---|
window | Zoom, theme, title bar, full screen, lifecycle, devtools, extra windows, global shortcut + open-in-tab events |
clipboard | copyImageToClipboard(buffer) for raw PNG bytes |
shell | openExternal, openPath, openFileUrl, downloadURL, openCustom — all validated in main process |
contextMenu | Subscribe to right-click events, dispatch cut/copy/paste/insertText |
spellcheck | addWordToDictionary, getAvailableSpellCheckerLanguages |
tray | reloadTray() |
printing | PDF export/preview/save, printer list, print progress events |
navigation | Back/forward history accessors and navigation events |
// Open a URL in the user's default browser
window.electronApi?.shell.openExternal("https://example.com");
// Toggle full screen
const api = window.electronApi;
if (api) api.window.setFullScreen(!api.window.isFullScreen());
// Read & adjust zoom
const zoom = window.electronApi?.window.getZoomFactor() ?? 1;
window.electronApi?.window.setZoomFactor(zoom + 0.1);
// React to global shortcuts (configured in Trilium options)
window.electronApi?.window.onGlobalShortcut((actionName) => {
console.log("shortcut fired:", actionName);
});
// Copy a PNG to the clipboard
const bytes = new Uint8Array(await blob.arrayBuffer());
window.electronApi?.clipboard.copyImageToClipboard(bytes);
Every shell.* call is validated in the main process and will throw on invalid input — the renderer is treated as untrusted:
openExternal(url): scheme is allowlisted. file:, data:, smb:, ldap:/ldaps:, jar:, view-source:, and Follina-class schemes are blocked.openPath(path): must resolve under the Trilium data dir or tmp dir. Returns an empty string on success, an error message on failure.openFileUrl(fileUrl): handles user-clicked file: links inside notes; resolves to any local path (no data/tmp sandbox — these links routinely point at arbitrary user documents). UNC file://host/share URLs are blocked (NTLM-leak prevention). Returns an empty string on success, an error message on failure.downloadURL(url): pinned to the app's own origin — cross-origin downloads are rejected.openCustom(filePath): must be a descendant of the tmp dir and the file must exist.Because window.electronApi is missing outside desktop, write code that degrades:
import { showMessage } from "trilium:api";
function openInBrowser(url) {
if (window.electronApi) {
window.electronApi.shell.openExternal(url);
} else {
window.open(url, "_blank", "noopener,noreferrer");
}
}
Avoid require("electron"), @electron/remote, and process — nodeIntegration is disabled and contextIsolation is enabled, so they aren't available in the renderer.
Only use legacy widgets if you specifically need jQuery or cannot use JSX.
// Language: JavaScript (Trilium frontend), Label: #widget
class MyWidget extends api.BasicWidget {
get position() { return 1; }
get parentWidget() { return "center-pane"; }
doRender() {
this.$widget = $("<div>");
this.$widget.append($("<button>Click me</button>")
.on("click", () => api.showMessage("Hello!")));
return this.$widget;
}
}
module.exports = new MyWidget();
Key differences from Preact:
api. global instead of importsget parentWidget() instead of parent fieldmodule.exports = new MyWidget() (instance) for most widgetsmodule.exports = MyWidget (class, no new) for note-detail-paneapi.RightPanelWidget, override doRenderBody() instead of doRender()For JSX, use import/export syntax between notes. For JavaScript (Trilium frontend), use module.exports and function parameters matching child note titles.