Use when interacting with a Trilium Notes server via the ETAPI REST API - creating, reading, updating, searching, or deleting notes, branches, attributes, at...
---
name: trilium-etapi
description: Use when interacting with a Trilium Notes server via the ETAPI REST API - creating, reading, updating, searching, or deleting notes, branches, attributes, attachments, day/week/month notes; obtaining auth tokens; or scripting Trilium operations from the shell. Triggers on mentions of Trilium, ETAPI, noteId, branchId, or any /etapi/ URL.
---
# Trilium ETAPI
## Overview
ETAPI is the external REST API for Trilium Notes (Trilium ≥ 0.50). All requests require token auth. Resources are addressed by 12-char IDs: `noteId`, `branchId`, `attributeId`, `attachmentId`.
**Core concepts:**
- **Note** — a content unit (HTML/code/file/image/...), identified by `noteId`
- **Branch** — the parent-child relationship between two notes (the same note can be cloned under multiple parents)
- **Attribute** — a label or relation attached to a note
- **Attachment** — a binary or text payload owned by a note
## When to Use
- Bulk-create or import notes via script (daily notes, inbox, capture)
- Push content into Trilium from external systems (RSS, email, webhooks)
- Search/export Trilium content for consumption by other tools
- Trigger server-side database backups, export subtrees
- Debug ETAPI integrations (clients like trilium-py / trilium-client just wrap this API)
**Do not use** for scripts running *inside* Trilium — use the frontend/backend Script API directly, no HTTP needed.
## Setup
All examples below assume:
```bash
export TRILIUM_URL="http://localhost:8080" # no trailing /etapi
export TRILIUM_TOKEN="<generate via Trilium → Options → ETAPI>"
```
If you only have a password (and the server allows password login), exchange it for a token:
```bash
curl -sX POST "$TRILIUM_URL/etapi/auth/login" \
-H 'Content-Type: application/json' \
-d '{"password":"YOUR_PASSWORD"}' | jq -r .authToken
```
**Three auth styles** (pick one, in the `Authorization` header):
1. `Authorization: $TRILIUM_TOKEN` — raw token (works on every version)
2. `Authorization: Bearer $TRILIUM_TOKEN` — Bearer form (v0.93+)
3. `Authorization: Basic $(echo -n "etapi:$TRILIUM_TOKEN" | base64)` — Basic auth (v0.56+)
## Quick Reference
| Operation | Method | Path |
|-----------|--------|------|
| Health / version | GET | `/etapi/app-info` |
| Search notes | GET | `/etapi/notes?search=...` |
| Read note metadata | GET | `/etapi/notes/{noteId}` |
| Read note content | GET | `/etapi/notes/{noteId}/content` |
| Write note content | PUT | `/etapi/notes/{noteId}/content` (text/plain) |
| Create note | POST | `/etapi/create-note` |
| Patch note metadata | PATCH | `/etapi/notes/{noteId}` |
| Delete note | DELETE | `/etapi/notes/{noteId}` |
| Export subtree as ZIP | GET | `/etapi/notes/{noteId}/export?format=html\|markdown` |
| Import ZIP | POST | `/etapi/notes/{noteId}/import` |
| Create / move branch | POST | `/etapi/branches` |
| Create attribute | POST | `/etapi/attributes` |
| Create attachment | POST | `/etapi/attachments` |
| Day note (auto-create) | GET | `/etapi/calendar/days/{YYYY-MM-DD}` |
| Inbox | GET | `/etapi/inbox/{YYYY-MM-DD}` |
| Trigger DB backup | PUT | `/etapi/backup/{name}` |
Full endpoint, parameter, and schema reference: [api-reference.md](api-reference.md).
## Core Patterns (curl + jq)
All snippets below assume `TRILIUM_URL` and `TRILIUM_TOKEN` are exported.
### 1. Health check
```bash
curl -s "$TRILIUM_URL/etapi/app-info" -H "Authorization: $TRILIUM_TOKEN" | jq
```
### 2. Create a text note
```bash
curl -sX POST "$TRILIUM_URL/etapi/create-note" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"parentNoteId": "root",
"title": "Hello from ETAPI",
"type": "text",
"content": "<p>Created via curl</p>"
}' | jq '{noteId: .note.noteId, branchId: .branch.branchId}'
```
Returns `NoteWithBranch`: the new `note` plus the `branch` mounting it.
### 3. Replace note content
Content lives on a separate endpoint and the body is `text/plain` **even when the content is HTML**:
```bash
curl -sX PUT "$TRILIUM_URL/etapi/notes/$NOTE_ID/content" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: text/plain' \
--data-binary @body.html
```
### 4. Search
```bash
# fulltext + label
curl -sG "$TRILIUM_URL/etapi/notes" \
-H "Authorization: $TRILIUM_TOKEN" \
--data-urlencode 'search=tolkien #book' \
--data-urlencode 'limit=10' | jq '.results[] | {noteId, title}'
```
Search syntax matches the Trilium UI search bar. Common forms: `#tag`, `#tag=value`, `note.content *= "..."`, `~relation.title = "..."`.
### 5. Tag a note
```bash
curl -sX POST "$TRILIUM_URL/etapi/attributes" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"noteId\": \"$NOTE_ID\",
\"type\": \"label\",
\"name\": \"book\",
\"value\": \"\",
\"isInheritable\": false
}"
```
### 6. Day note (auto-created on demand)
```bash
TODAY=$(date +%F)
curl -s "$TRILIUM_URL/etapi/calendar/days/$TODAY" \
-H "Authorization: $TRILIUM_TOKEN" | jq -r .noteId
```
### 7. Clone a note to another location (branch)
```bash
curl -sX POST "$TRILIUM_URL/etapi/branches" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: application/json' \
-d "{
\"noteId\": \"$CHILD_ID\",
\"parentNoteId\": \"$NEW_PARENT_ID\",
\"prefix\": \"\",
\"notePosition\": 100,
\"isExpanded\": false
}"
```
### 8. Export a subtree as ZIP
```bash
curl -s "$TRILIUM_URL/etapi/notes/$NOTE_ID/export?format=markdown" \
-H "Authorization: $TRILIUM_TOKEN" -o subtree.zip
# Whole document: use noteId="root"
```
### 9. Trigger a server-side backup
```bash
curl -sX PUT "$TRILIUM_URL/etapi/backup/now" \
-H "Authorization: $TRILIUM_TOKEN"
# Writes to dataDirectory/backup/backup-now.db
```
## Common Pitfalls
- **Don't add a `Bearer` prefix to `Authorization`** unless you're on v0.93+ and explicitly want Bearer. The raw token form works on every version.
- **`PUT /notes/{id}/content` body is `text/plain`** (NOT `text/html`). The OpenAPI spec is explicit on this.
- **`PATCH /notes/{id}` only patches a subset**: `title`, `type`, `mime`, `dateCreated`, `utcDateCreated`. Use `PUT .../content` to change content.
- **`PATCH /branches/{id}` only patches `prefix` and `notePosition`**. To re-parent a note you must DELETE the branch and POST a new one.
- **`PATCH /attributes/{id}`**: labels can only patch `value` and `position`; relations can only patch `position`. Anything else means delete + recreate.
- **Deleting the last branch of a note also deletes the note.** Watch out when DELETE-ing branches.
- **`notePosition` defaults to step 10** (10/20/30...). To insert in front, use 5; to push to the end, use a large value like 1000000. After bulk reorders, call `POST /refresh-note-ordering/{parentNoteId}` so connected clients refresh.
- **EntityId pattern is `[a-zA-Z0-9_]{4,32}`.** `root` is the special root noteId.
- **Error responses are always `{status, code, message}`.** Branch on the stable `code` constant (e.g. `NOTE_IS_PROTECTED`), not on the human-readable message.
- **`/auth/login` is rate-limited.** Too many failures returns 429 and temporarily blacklists the client IP.
## Note Types
| type | Use | mime required? |
|------|-----|----------------|
| `text` | Rich text (HTML) | no |
| `code` | Source code | yes (e.g. `text/x-python`) |
| `file` | Binary file | yes |
| `image` | Image | yes (e.g. `image/png`) |
| `search` | Saved search | no |
| `book` | Folder-style container | no |
| `relationMap` | Relation map | no |
| `render` | Custom renderer | no |
You may also see these on read: `noteMap`, `mermaid`, `webView`, `shortcut`, `doc`, `contentWidget`, `launcher`.
## Workflow: Append to today's day note
A typical inbox / capture flow — append arbitrary text to today's day note:
```bash
TODAY=$(date +%F)
NOTE_ID=$(curl -s "$TRILIUM_URL/etapi/calendar/days/$TODAY" \
-H "Authorization: $TRILIUM_TOKEN" | jq -r .noteId)
OLD=$(curl -s "$TRILIUM_URL/etapi/notes/$NOTE_ID/content" \
-H "Authorization: $TRILIUM_TOKEN")
NEW="${OLD}<p>$(date +%H:%M) — $1</p>"
curl -sX PUT "$TRILIUM_URL/etapi/notes/$NOTE_ID/content" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: text/plain' \
--data-binary "$NEW"
```
## When You Need More
- Full endpoint, parameter, and schema reference → [api-reference.md](api-reference.md)
- Python clients: [trilium-py](https://github.com/Nriver/trilium-py) or [trilium-client](https://pypi.org/project/trilium-client/)
- TypeScript types: [trilium-api](https://www.npmjs.com/package/trilium-api)
- Search syntax: see Trilium docs → Search
don't have the plugin yet? install it then click "run inline in claude" again.
separated authentication options into inputs, broke monolithic examples into 15 discrete procedure steps with explicit in/out, added decision points for version branching, auth failure, re-parenting, attribute patching, and search edge cases, documented output contracts and outcome signals for each step.
interact with a trilium notes server (v0.50+) via its etapi rest api to create, read, update, search, or delete notes, branches, attributes, and attachments. use this skill when you need to bulk-import content, push data from external systems (webhooks, rss, email), export trilium subtrees, or trigger backups from outside trilium. do not use this for scripts running inside trilium itself, those should use the internal script api.
environment variables (required)
TRILIUM_URL: base url of trilium server, no trailing slash. example: http://localhost:8080TRILIUM_TOKEN: etapi authentication token. generate in trilium ui under options > etapiauthentication methods (pick one, use in Authorization header)
Authorization: $TRILIUM_TOKEN (works on all versions)Authorization: Bearer $TRILIUM_TOKEN (v0.93+)Authorization: Basic $(echo -n "etapi:$TRILIUM_TOKEN" | base64) (v0.56+)optional: password exchange for token (if no token yet but password auth enabled)
POST /etapi/auth/login with json body {"password":"YOUR_PASSWORD"} returns authTokenexternal connection
TRILIUM_URL/auth/login: repeated failures (429) temporarily blacklist client ipinputs: TRILIUM_URL, TRILIUM_TOKEN
output: server version, auth confirmed or error with code
curl -s "$TRILIUM_URL/etapi/app-info" \
-H "Authorization: $TRILIUM_TOKEN" | jq
if auth fails (401), check token validity and regenerate in trilium ui. if server unreachable (connection refused), confirm TRILIUM_URL and trilium process is running.
inputs: TRILIUM_URL, TRILIUM_TOKEN, search query string
output: array of matching notes with noteId, title, type
curl -sG "$TRILIUM_URL/etapi/notes" \
-H "Authorization: $TRILIUM_TOKEN" \
--data-urlencode 'search=YOUR_QUERY' \
--data-urlencode 'limit=50' | jq '.results[]'
search syntax matches trilium ui search bar. examples: #tag, #tag=value, note.content *= "phrase", ~relation.title = "value".
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId
output: note object with noteId, title, type, mime, dateCreated, isProtected
curl -s "$TRILIUM_URL/etapi/notes/$NOTE_ID" \
-H "Authorization: $TRILIUM_TOKEN" | jq
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId
output: raw content as string (html for text notes, source for code notes, binary for files/images)
curl -s "$TRILIUM_URL/etapi/notes/$NOTE_ID/content" \
-H "Authorization: $TRILIUM_TOKEN"
inputs: TRILIUM_URL, TRILIUM_TOKEN, parentNoteId, title, type, content (optional), mime (required for code/file/image)
output: NoteWithBranch object containing new note and branch records
curl -sX POST "$TRILIUM_URL/etapi/create-note" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"parentNoteId": "root",
"title": "Note Title",
"type": "text",
"content": "<p>html content here</p>"
}' | jq '.note.noteId, .branch.branchId'
valid types: text, code, file, image, search, book, relationMap, render. code/file/image require mime field (e.g. text/x-python, application/octet-stream, image/png).
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId, content body
output: 200 ok or error code
critical: Content-Type header must be text/plain even for html content. body is sent as binary.
curl -sX PUT "$TRILIUM_URL/etapi/notes/$NOTE_ID/content" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: text/plain' \
--data-binary @file.html
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId, patch object
output: updated note object
note: only patches title, type, mime, dateCreated, utcDateCreated. to change content, use step 6.
curl -sX PATCH "$TRILIUM_URL/etapi/notes/$NOTE_ID" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"title": "New Title"}'
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId, type (label|relation), name, value, isInheritable
output: attribute object with attributeId
curl -sX POST "$TRILIUM_URL/etapi/attributes" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"noteId": "'$NOTE_ID'",
"type": "label",
"name": "priority",
"value": "high",
"isInheritable": false
}'
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId, parentNoteId, notePosition, prefix
output: branch object with branchId
note: to re-parent, delete old branch first then create new one. PATCH /branches only handles prefix and notePosition.
curl -sX POST "$TRILIUM_URL/etapi/branches" \
-H "Authorization: $TRILIUM_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"noteId": "'$CHILD_ID'",
"parentNoteId": "'$NEW_PARENT_ID'",
"prefix": "",
"notePosition": 100,
"isExpanded": false
}'
use notePosition=5 to insert at front, large value like 1000000 to push to end. default is 10. after bulk reorders, call POST /refresh-note-ordering/{parentNoteId}.
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId, title, binary file content
output: attachment object with attachmentId
curl -sX POST "$TRILIUM_URL/etapi/attachments" \
-H "Authorization: $TRILIUM_TOKEN" \
-F "file=@/path/to/file" \
-F "noteId=$NOTE_ID" \
-F "title=Attachment Title"
inputs: TRILIUM_URL, TRILIUM_TOKEN, date string YYYY-MM-DD
output: day note object with auto-created branches if not exist
curl -s "$TRILIUM_URL/etapi/calendar/days/2024-01-15" \
-H "Authorization: $TRILIUM_TOKEN" | jq '.noteId'
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId
output: 200 ok or error code
critical: deleting the last branch of a note also deletes the note itself. if note has multiple branches, only the branch is deleted.
curl -sX DELETE "$TRILIUM_URL/etapi/notes/$NOTE_ID" \
-H "Authorization: $TRILIUM_TOKEN"
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId, format (html|markdown)
output: zip file written to stdout or specified file
curl -s "$TRILIUM_URL/etapi/notes/$NOTE_ID/export?format=markdown" \
-H "Authorization: $TRILIUM_TOKEN" -o subtree.zip
to export whole document, use noteId=root.
inputs: TRILIUM_URL, TRILIUM_TOKEN, noteId, zip file
output: import result object
curl -sX POST "$TRILIUM_URL/etapi/notes/$NOTE_ID/import" \
-H "Authorization: $TRILIUM_TOKEN" \
-F "file=@subtree.zip"
inputs: TRILIUM_URL, TRILIUM_TOKEN, backup name
output: 200 ok or error code
curl -sX PUT "$TRILIUM_URL/etapi/backup/now" \
-H "Authorization: $TRILIUM_TOKEN"
writes to server's dataDirectory/backup/backup-now.db.
if you are on trilium < 0.93, use raw token form (Authorization: $TRILIUM_TOKEN), not bearer. raw form works on all versions 0.50+.
if you have a password but no token, exchange it first via POST /etapi/auth/login with json body {"password":"..."}. extract authToken from response. if auth fails with 429, the server is rate-limiting, wait before retry.
if you need to change note parent, do not use PATCH /branches. you must delete the old branch (DELETE /etapi/branches/{branchId}) then create a new one (POST /etapi/branches) with the new parentNoteId.
if you need to patch an attribute, only value and position are patchable for labels, and only position for relations. anything else (name, type) requires delete and recreate.
if you receive a 401 (unauthorized), confirm:
TRILIUM_TOKEN is set and non-emptyif search returns empty but you expect results, check:
#label, #label=value, note.content *= "text")if note content PUT fails with 400, ensure:
Content-Type header is text/plain not text/htmlif bulk note creation times out, add pagination:
--max-time 30 on curl to avoid hangingif export or import fails with file size errors, check:
if you see NOTE_IS_PROTECTED error code, the note is write-protected. you need to unprotect it in the ui first or use a different note.
health check output (step 1)
appVersion, hostname, other metadata, or error object with status, code, messagesearch output (step 2)
{results: [{noteId, title, type, mime, ...}, ...], hasMore: boolean}read metadata output (steps 3, 5)
{noteId, title, type, mime, dateCreated, utcDateCreated, isProtected, parentCount, ...}read content output (step 4)
create note output (step 5)
{note: {noteId, title, ...}, branch: {branchId, parentNoteId, ...}}patch metadata output (step 7)
attribute output (step 8)
{attributeId, noteId, type, name, value, isInheritable, ...}branch output (step 9)
{branchId, noteId, parentNoteId, notePosition, prefix, isExpanded, ...}attachment output (step 10)
{attachmentId, noteId, title, fileSize, role, ...}day note output (step 11)
{noteId, title, ...}delete output (step 12)
export output (step 13)
-o filename.zip)import output (step 14)
backup output (step 15)
all error responses follow format: {status: "error"|"not-found"|..., code: "ERROR_CODE", message: "human string"}. code is stable and should be used in conditionals, not message.
health check passes if step 1 returns 200 with appVersion field and no 401/403 error.
search finds notes if step 2 returns results array with length > 0 and matching titles/noteIds.
note created if step 5 returns noteId in response (non-empty string, 12 chars, alphanumeric).
content updated if step 6 returns 200 and subsequent read (step 4) returns the new content.
metadata patched if step 7 returns updated note object with your new field values.
attribute added if step 8 returns attributeId and subsequent read of note shows the attribute in ui.
branch created if step 9 returns branchId and note now appears under new parent in ui.
attachment uploaded if step 10 returns attachmentId and subsequent read of note lists attachment.
day note exists if step 11 returns noteId (created on demand if not exist).
note deleted if step 12 returns 200 and subsequent read (step 3) returns 404.
export completes if step 13 writes valid zip file to disk (can be unzipped without error).
import succeeds if step 14 returns json with import count > 0 and new notes appear in ui.
backup triggered if step 15 returns 200 and backup file appears in server dataDirectory/backup/.
credits: original skill by yanickxia at clawhub, enriched for implexa standards with explicit decision branches, auth options, edge cases, and output contracts.