Use when adding, moving, or wiring an internal REST endpoint in Trilium (a new `/api/*` route) — choosing between a core-shared handler (`packages/trilium-core/src/routes/index.ts` `buildSharedApiRoutes`, ~140 routes that ALSO run in the standalone sqlite-wasm build) and a server-only one (`apps/server/src/routes/routes.ts`), picking the `apiRoute` vs `asyncApiRoute` vs `route`/`asyncRoute` wrapper, and getting the implicit return conventions (object→200, `undefined`→204, `[status, body]` tuple→that status) and `convertEntitiesToPojo`'s narrow entity-unwrapping right. Most routes are core-shared (and run under WASM); `apps/server/src/routes/api/` is the minority case. Pairs with writing-unit-tests for the cross-runtime CoreApiTester spec.
Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.
Quelldateien prüfen
Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.
Mit Codex oder Claude installieren Kopieren Sie diesen Prompt, fügen Sie ihn in Codex, Claude oder einen anderen Assistant ein und lassen Sie die Skill-Seite prüfen und installieren.
Ein direkter Befehl überspringt den Prüf-Prompt. Prüfen Sie die Quelle, bevor Sie ihn ausführen.
Use when adding, moving, or wiring an internal REST endpoint in Trilium (a new `/api/*` route) — choosing between a core-shared handler (`packages/trilium-core/src/routes/index.ts` `buildSharedApiRoutes`, ~140 routes that ALSO run in the standalone sqlite-wasm build) and a server-only one (`apps/server/src/routes/routes.ts`), picking the `apiRoute` vs `asyncApiRoute` vs `route`/`asyncRoute` wrapper, and getting the implicit return conventions (object→200, `undefined`→204, `[status, body]` tuple→that status) and `convertEntitiesToPojo`'s narrow entity-unwrapping right. Most routes are core-shared (and run under WASM); `apps/server/src/routes/api/` is the minority case. Pairs with writing-unit-tests for the cross-runtime CoreApiTester spec.
Adding an internal API route in Trilium
The first decision is which routes/api/ directory. There are TWO, and the common assumption that all routes live server-side in apps/server/src/routes/api/ is wrong — that's the minority case. ~140 of the routes live in the core one:
A route you add only in routes.ts is invisible to the standalone build and cannot be driven by CoreApiTester. A core handler that imports node:* or touches process.envbreaks the WASM build. Pick the wrong side and you either lose standalone support or break it.
Footgun checklist (read before you start)
Default to core. Note/branch/attribute/tree/search/revision/import logic is browser-safe → put it in packages/trilium-core/src/routes/api/ and register in index.ts's buildSharedApiRoutes. Only Node-only or genuinely server-scoped routes go in routes.ts. (Step 1.)
Async handler ⇒ asyncApiRoute, never apiRoute.apiRoute runs sql.transactional(cb)synchronously (route_api.ts:100) — hand it an async function and it commits an empty transaction immediately; your awaited writes land outside any transaction. (Step 2.)
Return conventions are implicit. object/array/string → 200; undefined/nothing → 204; [integerStatus, body] tuple → that status. Throw ValidationError/NotFoundError for 400/404. (Step 3.)
convertEntitiesToPojo only unwraps narrow shapes. A BNote nested under result.notes[], result.parentNote, result.attributes[] serializes raw/broken — call .getPojo() yourself or shape under note/branch. (Step 4.)
Core handlers must be browser-safe: no process.env (use getPlatform().getEnv()), no node:path/import path (use services/utils/path.tsextname/basename), no Node built-ins.
Registration order matters cross-runtime — register a literal path before a same-method :param catch-all on the same prefix. (Step 5.)
Don't write the test by hand — core route ⇒ CoreApiTester; Express transport ⇒ supertest. Hand off to the writing-unit-tests skill. (Step 6.)
Step 1 — Decide: core-shared vs server-only
Put the handler in…
When
corepackages/trilium-core/src/routes/api/<m>.ts + register in index.tsbuildSharedApiRoutes
logic uses only browser-safe deps (becca, getSql(), core services, platform providers) AND the feature should work in standalone. Default for note/branch/attribute/tree/search/etc.
server-onlyapps/server/src/routes/api/<m>.ts + register in routes.tsregister()
needs Node-only deps not behind a platform provider (multer fs paths, OCR/LLM SDKs, metrics, sender, electron), or is genuinely server-scoped (etapi-tokens, totp, recovery codes)
The server-only set is small and specific — clipper, database, llm_chat, ocr, metrics, sender, totp, fonts, link_embed, recovery_codes, system_info, etapi_tokens, plus a few files extras. See the register() body in routes.ts (the shared set is wired by buildSharedApiRoutes at routes.ts:97). Everything else is core.
Browser-safe rule for core handlers: no process.env, no node:path/import path, no Node built-ins — they run in the sqlite-wasm standalone build. (script.ts:75,86 reads process.env.TRILIUM_SAFE_MODE directly — that is a latent violation, not a pattern to copy.)
Step 2 — Pick the wrapper
All four wrappers live in apps/server/src/routes/route_api.ts (apiRoute:69, asyncApiRoute:73, route:77, asyncRoute:81). Their names arrive via the buildSharedApiRoutes context object, so registering once in index.ts auto-wires all three call sites (server routes.ts:97, standalone browser_routes.ts:281, api_tester.ts:264).
A fifth name, asyncRouteWithoutTransaction, is also in the context object (index.ts:84) — use it for a long-running async handler that must not hold a transaction open.
Wrapper
Handler shape
Transaction
Auth + CSRF
Result handling
apiRoute(method, path, h)
synchronous(req) => value
sql.transactional (sync)
checkApiAuth + csrf, auto
apiResultHandler
asyncApiRoute(method, path, h)
async(req) => Promise
async (no sync wrap)
checkApiAuth + csrf, auto
apiResultHandler
route(method, path, [mw], h, resultHandler?)
sync; writes res directly OR custom [mw] (image/file download, setup, sync)
apiRoute → route(...true) → internalRoute(..., transactional=true); asyncApiRoute → asyncRoute(...false) → transactional=false. So apiRoute calls sql.transactional(cb)synchronously. If cb is async, sql.transactional sees a returned Promise, commits the (empty) transaction, and your awaited writes run after the commit, outside any transaction. The standalone adapter documents this exact trap in browser_routes.ts:127-133 ("would commit an empty transaction immediately when passed an async callback"). Async handler ⇒ asyncApiRoute. Always.
Auth/CSRF:apiRoute/asyncApiRoute attach auth.checkApiAuth + csrfMiddleware automatically (route_api.ts:70,74). Mutating endpoints therefore carry CSRF for free. Routes called from outside a browser session (setup, sync, sender, image/file download) use bare route()/asyncRoute() with an explicit [middleware] array (often checkApiAuthOrElectron or checkApiAuth) — see the route(...)/asyncRoute(...) registrations in index.ts and routes.ts.
Step 3 — Return value conventions
From apiResultHandler (route_api.ts:29-47), mirrored by the standalone apiResultHandler (browser_routes.ts:227) and CoreApiTester.formatApiResult (api_tester.ts:100):
Your handler returns…
HTTP status
Real example
object / array / string
200
most handlers
undefined / nothing
204
branches.tssetPrefix, setExpanded
[integer, body] (first elem is an int)
that status code
branches.ts:42,90[200, …]; import.ts:67,71,126[500, msg]; revisions.ts:123[400, "Description must be a string."]
throw new ValidationError(...) / NotFoundError(...)
400 / 404
mapped by handleException (route_api.ts:130); CoreApiTester maps HttpError→status at api_tester.ts:335
Direct-response handlers (image/download) write to res themselves and use route(), not apiRoute() — see the direct-response registrations in index.ts.
index.ts:365-390 only unwraps AbstractBeccaEntity instances in five shapes:
the top-level result is an entity (:366)
the top-level result is an array of entities (:368-372)
result.note (:375-376)
result.branch (:379-380)
(recursively) result.executionResult, from runOnBackend() (:384-387)
Anything else serializes raw. A BNote under result.notes[], result.parentNote, result.attributes[], result.attachment, or any other key is not unwrapped — JSON.stringify then ships the live entity (lazy getters, becca back-references) and the response is wrong or throws. Fix: call .getPojo() yourself when building the response, or place the entity under a recognized note/branch key. createTextNote relies on this: the create handler returns { note, branch } and both get unwrapped.
Step 5 — Registration order
Both Express andCoreApiTester match in registration order, first match wins — the tester iterates this.routes and returns on the first match (api_tester.ts:301). Register a literal path before a same-method :param catch-all on the same prefix. Precedent (etapi side): routes.ts:190 — "Register revisions routes BEFORE notes routes so /etapi/notes/history is matched before /etapi/notes/:noteId". Current core /api/notes/... literals avoid collisions only because they differ by HTTP method (e.g. POST /api/notes/erase-deleted-notes-now vs GET /api/notes/:noteId) — if you add a literal that shares a method with an existing :param route on the same prefix, put the literal first.
Step 6 — Test it (handoff, don't reinvent)
Don't hand-roll a route test. Use the existing harness:
createTextNote(api, {...}) → { noteId, branchId } lives in packages/trilium-core/src/test/api_fixtures.ts. A core spec runs cross-runtime — run both suites before calling it done (pnpm --filter server exec vitest run <spec> AND pnpm --filter standalone exec vitest run <spec>); the providers differ. See writing-unit-tests for the cross-runtime traps and the Windows/sandbox vitest invocation.
Quick recipe (core route, the common case)
Write getX/putX in packages/trilium-core/src/routes/api/<m>.ts as a sync(req) => value (or async if it awaits) handler; default-export the object of handlers.
Add the registration line to buildSharedApiRoutes in index.ts — apiRoute(GET, "/api/...", <m>Route.getX) for sync, asyncApiRoute(...) for async. That single line wires server + desktop + standalone + CoreApiTester.
Return a value (Step 3); keep it browser-safe (Step 1); unwrap entities under note/branch or via .getPojo() (Step 4); mind ordering (Step 5).
Add a co-located <m>.spec.ts with CoreApiTester and run both suites (Step 6).
Full wrapper matrix (handler shape, sync vs async transaction, middleware, result handler), the return-value table with real call sites, the convertEntitiesToPojo unwrap table, and how the three buildSharedApiRoutes call sites (Express route_api.ts, standalone browser_routes.ts, CoreApiTesterapi_tester.ts) each implement the same context.
Related skills:writing-unit-tests (server-and-core.md Pattern 0 = CoreApiTester, Pattern 1 = supertest transport) for testing; analyzing-coverage for chasing the new handler's coverage.