Every HTTP endpoint, in route-registration order. Auth notes apply to all endpoints unless overridden.
Auth: writes are gated by isWritable(vault, path) against appdata/config.json → rw_paths. Admin endpoints (/api/admin/*) require an X-Admin-Token header matching appdata/config.json → admin_token. Everything else is open (typically protected by a reverse proxy + forward auth like Authelia).
Conventions:
- All paths are vault-relative unless noted.
- All
vaultquery params use the vault directory name, not its display label. - Request/response bodies are JSON unless stated otherwise.
- All errors come back as
{"error": "<msg>"}with the appropriate status code.
Liveness check.
Response: 200 OK with body OK. No auth, no work.
Vault stats — note counts. Response:
{
"totalNotes": 1623,
"vaults": [
{"name": "pessoal", "noteCount": 581},
{"name": "work", "noteCount": 720}
]
}Proxies the configured Syncthing /rest/db/completion endpoint.
Response:
{"available": true, "state": "synced", "message": "Up to date"}States: synced / syncing / error / unknown.
List vault directory names, ordered by vaultOrder then alphabetical.
Response: ["pessoal", "work", "pcp", …]
Full directory tree for a vault. Skips .trash/, .obsidian/, hidden dirs.
Response:
[
{"name": "agents", "path": "agents", "isDir": true, "children": [...]},
{"name": "foo.md", "path": "foo.md", "isDir": false, "mtime": 1777411898, "size": 1234}
]Serves the vault's icon file from appdata/icons/<vault>.<ext>. Extension lookup order: .png, .svg, .jpg, .webp — first match wins. Returns image/*. 204 No Content if missing — frontend falls back to a generic SVG.
Read a note. Response:
{
"raw": "---\ntitle: Foo\n---\n# Body",
"html": "<h1>Body</h1>",
"frontmatter": {"title": "Foo"},
"backlinks": [{"vault": "pessoal", "path": "other.md", "title": "Other", "excerpt": "…"}],
"mtime": 1777411898,
"size": 234
}Errors: 400 invalid vault/path, 404 note not found.
Create a new note. Won't overwrite — returns 409 note already exists if the path exists.
Body: {"raw": "initial content"}
Response: 201 Created with {"path": "foo.md", "status": "created"}.
Save an existing note.
Body: raw markdown (Content-Type: text/plain; charset=utf-8)
Response: 200 OK with {"mtime": <new_unix>} so the client stays in sync.
If ifMTime is supplied and the on-disk file is newer (with 1-second slop), returns 409 Conflict with body:
{
"error": "file changed on disk",
"diskMtime": 1777411900,
"diskRaw": "<current file content>"
}The client uses this to render the conflict-resolution modal.
Move to <vault>/.trash/ using the VRTRASH_<base64url(originalPath)>_<unix><ext> naming scheme. Soft delete, recoverable via /api/trash/restore.
Response: {"status": "deleted", "movedTo": ".trash/VRTRASH_…<unix>.md"}. The movedTo value is what to pass to /api/trash/restore to undo (used by the undo-toast flow).
Image upload. Multipart form. Cap: 10MB.
Form fields: vault, notePath (the containing note), file.
Validation: Content-Type must start with image/; ext inferred from MIME (png/jpg/gif/webp/svg).
Side effect: writes to <note-dir>/attachments/<note-base>-<unix>.<ext> (creating attachments/ if missing).
Response: {"path": "attachments/<filename>"} (relative to the note's dir, ready to embed as ![[…]]).
Rename or move a note/folder.
Body: {"vault": "...", "fromPath": "...", "toPath": "..."}
Response: 204 No Content.
Create folder.
Body: {"vault": "...", "path": "..."}
Response: 201 Created.
Move folder to trash. Same VRTRASH_<base64>_<unix> naming as note delete.
Response: {"status": "deleted", "movedTo": ".trash/VRTRASH_…<unix>"}.
Search over notes + image attachments in the vault. Results scored and sorted; capped at 20 per vault.
Query language:
Plain text (my note) does substring matching against filename + title + body. Add operators to filter:
| Operator | Behavior |
|---|---|
tag:foo |
Frontmatter tags contain foo (substring + hierarchical match) |
path:bar |
Vault-relative path contains bar |
title:baz |
First-H1 title contains baz |
modified:>7d |
Modified within last N days/weeks/months/years (d/w/m/y) |
modified:<2026-01-01 |
Modified before this absolute date |
modified:>2026-01-01 |
Modified after this absolute date |
modified:=2026-04-29 |
Modified on this date |
Operators AND together. Plain text after operators acts as the body substring filter. Operator-only queries (tag:work modified:>7d) return everything matching, sorted by recency.
Scoring for note hits (kind="note" or omitted):
- exact title equals query: +20
- title substring: +10
- filename substring: +5
- per body occurrence (capped 5): +1
- recency (linear over 30 days): +0–3
Image attachment hits (kind="image"): scored at 3 base + 1 if filename starts with the query + recency boost. Strictly lower than note matches so notes rank first when the query matches both.
Response:
[
{"vault": "pessoal", "path": "leituras/Caffeine.md", "title": "Caffeine", "excerpt": "…"},
{"vault": "pessoal", "path": "misc/screenshot-x.png", "title": "screenshot-x.png", "excerpt": "", "kind": "image"},
…
]The kind field is only set for image results; notes omit it (or set it to "note").
Resolve a wikilink target to {vault, path}.
Response:
{"vault": "pessoal", "path": "leituras/Caffeine.md"}404 if no match.
Backlinks for a single note. Reads the in-memory inverted index — no disk read of the note itself, much cheaper than a full GET /api/note when you only need the link list (used by the rename-warning flow).
Response:
{
"backlinks": [
{"vault": "pessoal", "path": "leituras/Coffee.md", "title": "Coffee", "excerpt": "…"}
]
}List .md files under <vault>/templates/ for the "From template…" picker.
Response:
{
"templates": [
{"path": "templates/Meeting.md", "name": "Meeting", "body": "---\\ndate: {{date}}\\n---\\n\\n# {{title}}\\n"}
]
}Empty array if the templates folder doesn't exist.
Serves any file in the vault verbatim. Used by image embeds — the renderer rewrites ![[image.png]] to <img src="/api/file?vault=…&path=…">.
Aggregate every frontmatter tags: / tag: value across all notes.
Response:
{
"tags": [
{"tag": "AI-Processed", "count": 1080, "vaults": ["pcp", "pessoal", "projects", "sosracismo", "work"]},
{"tag": "jll", "count": 346, "vaults": ["work"]}
],
"total": 463
}Sorted by count desc, then tag asc.
Wikilink graph. Three scopes (mutually exclusive — center wins):
| Mode | Query | Meaning |
|---|---|---|
| All vaults | (no params) |
Every note across every vault |
| Vault | ?vault=pessoal |
Just that vault |
| Folder | ?vault=pessoal&folder=daily |
Only notes under daily/ |
| Ego | ?center=pessoal:foo.md&depth=1 |
foo.md plus everyone within N hops via outbound + inbound |
Response:
{
"nodes": [
{"id": "pessoal:leituras/Caffeine.md", "label": "Caffeine", "vault": "pessoal", "path": "leituras/Caffeine.md", "refs": 3, "isCenter": true}
],
"edges": [
{"id": "pessoal:leituras/Caffeine.md->pessoal:leituras/Tea.md",
"source": "pessoal:leituras/Caffeine.md",
"target": "pessoal:leituras/Tea.md"}
],
"vault": "pessoal",
"folder": "",
"center": "pessoal:leituras/Caffeine.md",
"depth": 1
}refs is the count of incoming edges within the current scope. isCenter is set on the center node for ego graphs only. Depth is clamped to [0, 5].
Mint a new share token.
Body: {"vault": "...", "path": "...", "writable": false, "ttl": 3600, "label": "..."}
TTL is in seconds; 0 = never expires.
Response: {"token": "94a75c66...", "url": "/share/94a75c66..."}.
List all active (non-expired) shares.
Response: array of ShareEntry { token, vault, path, writable, created_at, expires_at, label }.
Delete a single share.
Response: {"status": "revoked"}.
Bulk-revoke every active share in one call. Returns {"revoked": <count>}. Avoids the rate-limit cliff that a per-token loop would hit on 100+ shares.
Public share-link page. Returns the rendered note with no editor, no sidebar, no navigation. If the token is expired or revoked → 404. Includes:
- Inline image embeds (rewritten to
/share/<token>/file?…URLs). - Mermaid diagrams + KaTeX math, lazy-loaded from
/share/<token>/asset?…only when the page actually contains them.
If writable: true, the page also includes a CodeMirror editor with autosave (uses PUT /share/<token> with the share token as auth).
Serves a file inside the shared note's vault. Used by image embeds; the URL rewrite happens server-side in handleShareView. Strict checks: safePath against the share's vault, file-extension allowlist (images / PDF / common audio + video). Anything else returns 403 / 404 — a leaked token cannot be used to read arbitrary .md files.
Serves a bundled static asset for the share-page renderer. Strict allowlist:
mermaid.min.jskatex.min.js,katex.min.css,katex-auto-render.min.jsfonts/<KaTeX font>.woff2|woff|ttf
The KaTeX CSS is post-processed at-serve-time to rewrite relative url(fonts/…) to absolute /share/<token>/asset?name=fonts/… so fonts load under the same auth context. Anything outside the allowlist → 404, even if it exists in the embedded static bundle.
List trash entries for a single vault.
Response: {"items": [{"name": "<original-path>", "path": ".trash/VRTRASH_<b64>_<unix>.md", "isDir": "false"}]}.
The name field is the decoded original vault-relative path (display-friendly). The path field is the actual trash entry (use this for restore / delete).
Move a trash item back to its original location, decoded from the VRTRASH_<base64>_<unix> filename. Falls back to the legacy __→/ decoder for entries created before that scheme existed.
Response: 204 No Content.
With path → permanently delete one item.
Without path → empty the entire vault's .trash/.
Response: 204 No Content.
Walk the vault, return every image with metadata + reference count from a scan of all .md files.
Response:
{
"items": [
{"vault": "pessoal", "path": "misc/foo.png", "name": "foo.png", "size": 12345, "mtime": 1777411898, "ext": ".png", "refCount": 0}
],
"total": 73,
"orphan": 1
}Sorted: orphans first, then mtime desc.
Soft-delete to <vault>/.trash/. Same convention as note delete.
Response: 204 No Content.
Response: {"admin_token": "REDACTED", "rw_paths": [...]} (token redacted in the response).
Body: new config (JSON). Atomic write to appdata/config.json.
Response: 204 No Content.
Re-execs the binary in place via syscall.Exec. Container orchestrator brings it back up automatically (or it just restarts in-process).
Response: 204 No Content.
When admin_token is empty, all admin endpoints return 403 admin not configured.
Read-only WebDAV mount over the entire vaults dir. Method allowlist:
GET,HEAD— fetch a file or list a collectionOPTIONS— capabilities (advertisesDav: 1, 2)PROPFIND— enumerate
Mutating verbs (PUT, DELETE, MKCOL, COPY, MOVE, LOCK, PROPPATCH, UNLOCK) return 405 Method Not Allowed.
Use case: point Obsidian Mobile, GoodReader, Files.app, or any generic WebDAV client at https://notes.example.com/webdav/<vault>/.
The endpoint exposes everything under the vaults dir, including .obsidian/, .trash/, .smart-env/. Filter at your reverse proxy if you want to hide them.
Anything not matched by the routes above falls through to http.FileServer(http.FS(staticFiles)):
/index.html,/style.css— the SPA shell/codemirror.bundle.js,/mermaid.min.js,/katex.min.js,/katex-auto-render.min.js,/cytoscape.min.js,/alpine.min.js— third-party libs/katex.min.css,/fonts/*.woff2|.woff|.ttf— KaTeX styling/obsidian.svg— fallback vault icon
Served straight from the embedded FS — no disk read, no network.
X-Real-IP,X-Forwarded-For— used by the rate limiter to identify the real client behind a proxy.X-Admin-Token— admin auth.Content-Type— required on uploads (must start withimage/).If-Match,If-None-Match— not honored. Conflict detection uses?ifMTime=query param instead.
Content-Type: application/jsonfor all JSON responses.Content-Type: text/html; charset=utf-8for the SPA shell.Content-Type: <inferred>for/api/fileand/api/vault-icon.- Gzip is applied transparently by the
gzipMiddlewarewhen the request advertisesAccept-Encoding: gzip.
| Code | When |
|---|---|
| 200 | Successful GET / PUT |
| 201 | Successful note/folder create |
| 204 | Successful destructive op (delete, restore, etc.) |
| 400 | Invalid vault/path/body shape |
| 403 | Admin auth failure or write to a non-RW path |
| 404 | Note / share / file not found |
| 405 | Wrong HTTP method on a method-restricted endpoint |
| 409 | Conflict (note already exists, or file changed on disk) |
| 500 | Server error (rare; usually fs problems) |