| name | adding-internal-api-route |
| description | 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.env breaks 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.ts extname/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 |
|---|
core packages/trilium-core/src/routes/api/<m>.ts + register in index.ts buildSharedApiRoutes | 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-only apps/server/src/routes/api/<m>.ts + register in routes.ts register() | 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) | sync | explicit [mw] | optional |
asyncRoute(method, path, [mw], h, resultHandler?) | async + explicit [mw] (import/export, multipart) | async | explicit [mw] | optional |
The sync-transactional trap (route_api.ts:100):
return transactional ? sql.transactional(cb) : cb();
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.ts setPrefix, 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.
Step 4 — Entity serialization (convertEntitiesToPojo)
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 and CoreApiTester 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:
| What you added | Test with | Where |
|---|
| core handler | CoreApiTester.build() (packages/trilium-core/src/test/api_tester.ts) | co-located packages/trilium-core/src/routes/api/<m>.spec.ts — runs under both server and standalone suites |
| Express transport (CSRF/auth wiring) | supertest agent + bootLoggedInApp() | apps/server/src/routes/api/core_routes_http.spec.ts |
| server-only route | supertest agent | co-located apps/server/src/routes/api/<m>.spec.ts |
Minimal core spec (full patterns + fixtures live in the writing-unit-tests skill, server-and-core.md Pattern 0/1 — reference it, don't restate):
import { beforeAll, describe, expect, it } from "vitest";
import { createTextNote } from "../../test/api_fixtures";
import { CoreApiTester } from "../../test/api_tester";
let api: CoreApiTester;
describe("Widget API (core)", () => {
beforeAll(() => { api = CoreApiTester.build(); });
it("returns the widget", async () => {
const note = await createTextNote(api, { title: "Source" });
const res = await api.get(`/api/widgets/${note.noteId}`);
expect(res.status).toBe(200);
});
});
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).
Reference map
| File | What it covers |
|---|
| references/wrappers-and-conventions.md | 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, CoreApiTester api_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.