Documentation
Everything needed to run secondbrain and to understand what the agent on the other end is allowed to do. For reverse proxies and backups see the deployment guide; the German specification in spezifikation.md is authoritative where this page and it disagree.
Quick start
mkdir -p ./data && sudo chown -R 65532:65532 ./data docker run -d --name secondbrain -p 2020:2020 \ -v "$PWD/data:/data" \ -e SECONDBRAIN_PUBLIC_URL=https://brain.example.com \ -e SECONDBRAIN_USERNAME=andreas \ -e SECONDBRAIN_PASSWORD='bcrypt:$2a$12$...' \ --user 65532:65532 --cap-drop ALL \ ghcr.io/andreaskasper/secondbrain:latest
Or with Compose:
services:
secondbrain:
image: ghcr.io/andreaskasper/secondbrain:latest
ports: ["2020:2020"]
volumes:
- ./data:/data
environment:
SECONDBRAIN_PUBLIC_URL: https://brain.example.com
SECONDBRAIN_USERNAME: andreas
SECONDBRAIN_PASSWORD: "bcrypt:$2a$12$..."
user: "65532:65532"
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
docker compose up belongs to root, the first write fails, and the container restarts in a loop. Run chown -R 65532:65532 on the directory before the first start. This is the single most common first-deployment failure; the deployment guide says more.
Then point an MCP client at https://brain.example.com/mcp. The client discovers the OAuth endpoints, registers itself and opens the login page in a browser.
Building from source instead:
git clone https://github.com/andreaskasper/secondbrain cd secondbrain/src CGO_ENABLED=0 go build -o secondbrain . SECONDBRAIN_DATA=../data \ SECONDBRAIN_PUBLIC_URL=http://localhost:2020 \ SECONDBRAIN_USERNAME=andreas SECONDBRAIN_PASSWORD=secret ./secondbrain
Go 1.25, no framework, CGO_ENABLED=0. SQLite is modernc.org/sqlite, a pure-Go implementation, so the binary is static and the image is distroless.
Environment
Environment first. A single user with a single vault needs three variables and nothing else. A config file is only necessary when there is more than one user — and if a config file defines users, it wins outright and the two username variables are ignored.
| Variable | Default | Meaning |
|---|---|---|
SECONDBRAIN_USERNAME | — | Login name. Required unless config.yaml defines users. |
SECONDBRAIN_PASSWORD | — | A literal password, or bcrypt:$2a$12$..., or env:NAME, or file:/path. Prefer a hash: it means the plaintext exists nowhere on the host. |
SECONDBRAIN_PUBLIC_URL | — | Required. The external base URL, no trailing slash. OAuth discovery documents are generated from it, so a wrong value produces a client that cannot complete the flow. |
SECONDBRAIN_LISTEN | :2020 | Listen address inside the container. |
SECONDBRAIN_DATA | /data | Root directory holding one subdirectory per vault. |
SECONDBRAIN_DEFAULT_VAULT | default | The vault used when a tool call does not name one. |
SECONDBRAIN_CONFIG | /etc/secondbrain/config.yaml | Optional config file. Its absence is not an error. |
SECONDBRAIN_GIT | true | Commit every write. Turning it off removes note_history, note_diff and note_restore along with it. |
SECONDBRAIN_GIT_REMOTE | — | When set, push after every commit. An off-site copy that needs no scheduler. |
SECONDBRAIN_GIT_TOKEN | — | Credential for that push. |
SECONDBRAIN_GIT_AUTHOR | secondbrain | Commit author name. |
SECONDBRAIN_GIT_EMAIL | — | Commit author email. |
SECONDBRAIN_MAX_RESPONSE_BYTES | 262144 | Upper bound on a single tool response. Long notes are truncated with a marker rather than silently cut. |
SECONDBRAIN_TOKEN_TTL | 12h | Access token lifetime. |
SECONDBRAIN_CODE_TTL | 60s | Authorization code lifetime. |
SECONDBRAIN_TRASH_RETENTION | 720h | How long deleted and overwritten content stays in .secondbrain/trash/. Thirty days. |
SECONDBRAIN_ALLOWED_ORIGINS | — | Comma-separated allow-list for the Origin header. Empty means no check, which is correct for a server reached only by a native MCP client. |
SECONDBRAIN_LOG_LEVEL | info | debug, info, warn, error. No level ever prints note content. |
SECONDBRAIN_METRICS | false | Expose a Prometheus endpoint. Off unless asked for; see Metrics. |
SECONDBRAIN_METRICS_PATH | /metrics | Where the endpoint lives. Must start with a slash and may not collide with /mcp, /healthz or /. |
SECONDBRAIN_METRICS_KEY | — | Shared key a scraper must present, as Authorization: Bearer or as X-API-Key. Takes the same env: and file: prefixes as a password, and is refused below 16 characters. |
SECONDBRAIN_METRICS_LISTEN | — | Serve metrics on their own address, for example :9090, instead of on the main listener. A second /healthz comes with it. |
Password prefixes
The same four forms are accepted everywhere a password appears, in the environment and in the config file.
| Form | Meaning |
|---|---|
secret | The literal password. Convenient for a laptop, wrong for a server. |
bcrypt:$2a$12$... | A bcrypt hash produced by secondbrain hashpw. The recommended form. |
env:NAME | Read from another environment variable at startup. |
file:/run/secrets/pw | Read from a file, which is what Docker and Kubernetes secrets give you. |
config.yaml
The config file exists for one reason: more than one user. Each user carries the vaults it may open and whether it may write, so the question that matters — what exactly can this login reach? — is answered by reading one contiguous block.
# Every setting is top level. There is no server: block - the file mirrors
# the environment variables one for one, minus the SECONDBRAIN_ prefix.
listen: ":2020"
public_url: "https://brain.example.com" # required
data_dir: "/data"
default_vault: "personal"
max_response_bytes: 262144
token_ttl: "12h"
trash_retention: "720h"
login_rate_limit: "10/m"
git: true
metrics: false
metrics_path: "/metrics"
metrics_key: "file:/run/secrets/metrics_key" # at least 16 characters
metrics_listen: ":9090" # a port you do not publish
users:
# Full access to every vault on the server.
- name: andreas
password: "bcrypt:$2a$12$..."
vaults: [] # empty means all
read_only: false
# A second agent that may read everything but change nothing.
- name: reviewer
password: "env:REVIEWER_PASSWORD"
vaults: []
read_only: true
# A work assistant that never sees the private vault.
- name: work
password: "file:/run/secrets/work_password"
vaults: [work]
read_only: false
Three properties of that file are worth stating explicitly.
vaults: []means every vault, including vaults created later. Naming vaults explicitly is the safer habit, because a vault created next month is then not automatically reachable by an old login.- A read-only user is not offered the mutating tools at all. They are absent from
tools/list, not refused on call. A model cannot be talked into using a tool it was never shown. - The file wins. If it defines
users,SECONDBRAIN_USERNAMEandSECONDBRAIN_PASSWORDare ignored rather than merged, so there is never a second, forgotten login.
Validate before restarting anything:
docker run --rm -v "$PWD/config.yaml:/etc/secondbrain/config.yaml:ro" \ ghcr.io/andreaskasper/secondbrain validate /etc/secondbrain/config.yaml
CLI
The binary is the server. The subcommands are the few things you need to do around it.
| Command | What it does |
|---|---|
secondbrain | Run the server. This is the container's entrypoint. |
secondbrain validate [path] | Parse and check a config file, then exit. Non-zero on any problem, so it belongs in a deployment pipeline. |
secondbrain reindex [path] | Rebuild .secondbrain/index.db from the files on disk. Never needed in normal operation — the index reconciles at start and follows the watcher — but it is the answer to any doubt about the index, because the index is a cache. |
secondbrain hashpw | Prompt for a password and print a bcrypt hash for SECONDBRAIN_PASSWORD or the config file. |
secondbrain version | Version, commit and build date. |
Metrics
A Prometheus endpoint, off by default. A knowledge base is not a public service, and an endpoint that reports how many notes somebody has and when they last wrote one is not something to expose unasked. SECONDBRAIN_METRICS=true turns it on, and secondbrain validate prints the resulting state — the path, the address it is served on, and whether anything is guarding it.
There is no Prometheus client library behind it. The exposition format is a dozen lines of text, and pulling in a dependency tree to produce it is a poor trade in a container that holds somebody's private notes, so the registry is hand-rolled like the rest of the server.
SECONDBRAIN_METRICS=true SECONDBRAIN_METRICS_LISTEN=:9090 SECONDBRAIN_METRICS_KEY=file:/run/secrets/metrics_key
Keeping it private
Two mechanisms, independent of one another, and they compose.
- A shared key.
SECONDBRAIN_METRICS_KEY, presented by the scraper asAuthorization: Bearer <key>or asX-API-Key, compared in constant time. It takes the sameenv:andfile:prefixes as a password, and it is refused below sixteen characters. - A separate listener.
SECONDBRAIN_METRICS_LISTEN=":9090"serves the endpoint, and a second/healthzbeside it, on an address of its own. This is the stronger of the two: a port bound inside the Docker network and never mapped cannot be reached from outside whatever the key is, whereas a key is only ever as good as the file it sits in on the scraping host.
With metrics on and neither of them set, the server logs metrics_unprotected once at startup, naming the path and hinting at both fixes. It does not refuse to start — on loopback an open endpoint is reasonable — but the line is there so that an accident is visible in the log rather than only on the network.
The endpoint has its own rate limit bucket, sixty requests a minute per source IP. It is deliberately not the login bucket: a scraper with the wrong key can then neither be used as a timing oracle nor exhaust the budget that protects the login page.
metrics_listen is set the handler is registered on that second listener only, so the main listener answers a genuine 404 rather than a 401 — from outside there is nothing there to authenticate against, which is the point.
What is exposed
Everything below is a count or a gauge over something the server already knows. No metric carries a note path, a title, a tag or a line of content, and the route label on secondbrain_http_requests_total is drawn from a fixed list of known routes rather than from r.URL.Path, so a caller cannot mint labels by requesting invented URLs. The low cardinality is a privacy property and also the difference between a time series database that works and one that falls over.
Gauges
| Metric | Meaning |
|---|---|
secondbrain_build_info{version,commit} | Always 1. The version and the commit are the labels. |
secondbrain_uptime_seconds | Seconds since the process started. |
secondbrain_users | Configured users. |
secondbrain_oauth_clients | OAuth clients registered since start. In memory only, so a restart resets it. |
secondbrain_access_tokens | Live access tokens. |
secondbrain_refresh_tokens | Live refresh tokens. |
secondbrain_mcp_sessions | Open MCP sessions. |
secondbrain_vault_notes{vault} | Markdown notes in a vault. |
secondbrain_vault_words{vault} | Words across a vault's notes. |
secondbrain_vault_bytes{vault} | Bytes across a vault's notes. |
secondbrain_vault_tags{vault} | Distinct tags in a vault. |
secondbrain_vault_links{vault} | Internal links in a vault. |
secondbrain_vault_broken_links{vault} | Links pointing at a note that does not exist. |
secondbrain_vault_orphans{vault} | Notes with no link in or out. |
secondbrain_vault_open_tasks{vault} | Unchecked task boxes. |
secondbrain_vault_attachments{vault} | Non-Markdown files in a vault. |
Counters
| Metric | Meaning |
|---|---|
secondbrain_tool_calls_total{tool,outcome} | Tool calls. outcome is ok, error or denied. |
secondbrain_tool_duration_seconds_total{tool} | Cumulative time spent inside each tool. |
secondbrain_tool_result_bytes_total{tool} | Cumulative result bytes returned per tool. |
secondbrain_http_requests_total{path,status} | HTTP requests by route and status code. |
secondbrain_logins_total{outcome} | Login attempts. outcome is success, failed or rate_limited. |
secondbrain_writes_total | Mutating tool calls that actually wrote. |
secondbrain_dry_runs_total | Mutating tool calls that were dry runs. |
secondbrain_truncated_results_total | Results cut short by max_response_bytes. |
secondbrain_index_updates_total | Single-path index updates. |
secondbrain_index_reconciles_total | Full index reconciliations. |
secondbrain_index_errors_total | Index operations that failed. |
secondbrain_watch_events_total | Filesystem changes picked up by the watcher. |
secondbrain_git_commits_total | Commits made. |
secondbrain_git_failures_total | Commits or pushes that failed. |
secondbrain_trash_purged_total | Trashed copies removed after the retention window. |
The vault gauges come from the search index rather than from the files, so a scrape is a handful of counting queries against SQLite and not a walk of the filesystem. Scraping every fifteen seconds costs nothing worth measuring.
What to actually watch
The vault gauges are the operationally interesting ones, because they describe the health of the knowledge base rather than the health of the process. secondbrain_vault_broken_links and secondbrain_vault_orphans climbing steadily over weeks is a knowledge base decaying — notes being written that nothing points at, links left behind by renames — and that is invisible from inside any single note. secondbrain_vault_notes flat while secondbrain_writes_total climbs means an agent is editing but never creating, which is usually a conventions problem in instructions.md rather than a fault. secondbrain_truncated_results_total rising means max_response_bytes is too small for the way the vault is being queried, and the agent is working from clipped results. On the process side, secondbrain_git_failures_total above zero means the off-site copy has quietly stopped happening, and secondbrain_logins_total{outcome="failed"} is the series to alert on.
The Prometheus side of it — publishing the port only inside the Compose network, generating the key and the scrape config that reads it — is in the deployment guide.
Storage
One directory per vault under the data root. A vault name must match ^[a-z0-9][a-z0-9_-]{0,63}$, which keeps it a valid directory name on every filesystem and a valid path segment in a tool argument.
/data/
personal/
inbox/
wiki/
postgres.md
journal/2026/2026-07-30.md
attachments/
.secondbrain/
index.db # SQLite + FTS5, a cache
trash/ # timestamped copies, purged after retention
instructions.md # sent to the client on initialize
.git/ # one repository per vault
work/
...
Everything in .secondbrain/ is disposable. Delete the directory and the next start rebuilds the index from the files; you lose the trash and the vault instructions, and nothing else. That is the whole point: the notes are the product.
Path safety
Path handling is structural rather than a blacklist. After cleaning, a path must satisfy three conditions:
- it is relative, not absolute;
- it stays inside the vault after resolution, so no
..can climb out; - no component begins with a dot.
The third rule is doing more work than it looks. It makes .git, .obsidian and .secondbrain unreachable through every tool, present and future, without anyone having to remember to add them to a list. A rule that cannot be forgotten is worth more than a list that can.
Writes
Every write goes to a temporary file in the same directory and is then renamed over the target. A crash mid-write leaves either the old file or the new one, never half of either. The rename is followed by the git commit, so history and disk agree.
Vault layouts
vault_create takes a layout, creates the directories and writes .secondbrain/instructions.md describing them. Those instructions are handed to the client in the MCP initialize response, which is how a vault teaches an agent its own conventions without anything living in a system prompt somewhere else.
wiki-raw default
inbox/ raw/ wiki/ journal/ projects/ people/ attachments/ templates/
The organising idea is a hard line between material you collected and material you wrote. Raw source — a pasted article, a transcript, an export — is never rewritten. Distilled wiki notes are rewritten constantly. Mixing them ruins a vault, because you can no longer tell which sentences you are allowed to change.
zettelkasten
fleeting/ literature/ zettel/ journal/ attachments/ templates/
Atomic, densely linked notes: one idea per note, the links carrying the structure instead of the directories. Suits people who already work this way; frustrating for people who do not, because it asks for a decision at capture time.
para
projects/ areas/ resources/ archive/ journal/ attachments/ templates/
Organised by actionability rather than by subject. The strength is that finished work moves to archive/ and stops competing for attention; the cost is that the same subject can appear in three places over its life.
empty
attachments/ templates/
Two directories and nothing else, for a vault whose structure already exists — an imported Obsidian vault, or a repository you are pointing the server at for the first time.
Every layout uses the same journal convention: journal/YYYY/YYYY-MM-DD.md. daily_note and daily_range depend on it, so it is the one path shape that is not yours to change.
Notes and links
A note is a Markdown file with optional YAML frontmatter. The dialect is Obsidian's, because that is the editor most likely to be open on the same directory.
--- title: PostgreSQL tags: [database, ops] aliases: [postgres, pg] created: 2026-03-11 updated: 2026-07-30 --- # PostgreSQL Notes on running it, not on using it. See [[Connection pooling]] and [[wiki/backups#postgres|the backup section]]. ## Connection pooling Pool size is per process, not per host. #ops - [ ] measure the real ceiling on the reporting box - [x] move pgbouncer to transaction mode
| Convention | Notes |
|---|---|
[[wikilink]] | Resolved by note title, alias or path. Broken links are reported by note_backlinks and vault_review rather than fixed silently. |
[[link#anchor|alias]] | Anchor and display text are both understood, and both survive a note_move that rewrites the link. |
[text](path.md) | Ordinary Markdown links are indexed and rewritten the same way. |
#tag | Inline tags and frontmatter tags are merged into one namespace, so tag_list and tag_rename see both. |
aliases | Alternative names a wikilink may use. note_merge adds the source note's title here so old links keep resolving. |
created / updated | Maintained automatically — but only for notes that already have frontmatter or are being created now. A hand-written note without metadata is never given any. |
- [ ] | Checkboxes anywhere in the vault are the task list. task_list reports file and line; task_toggle flips one. |
That last rule is deliberate and occasionally surprising. If you keep a plain file with no frontmatter, this server will not quietly add three lines of YAML to the top of it the first time an agent appends a sentence. Files you wrote by hand keep looking like files you wrote by hand.
Search
Full-text search is SQLite FTS5 in <vault>/.secondbrain/index.db, ranked with BM25 and returned with a snippet per hit. The index is reconciled against the filesystem at startup and then kept current by an fsnotify watcher with a 400 ms debounce.
That combination is what makes external editing normal rather than dangerous. Obsidian saving a file, a git pull, an rsync from a laptop — each is just a file event, and the index catches up within half a second. Nothing owns the directory.
| Query | Meaning |
|---|---|
postgres pooling | Bare words are ANDed. |
"connection pool" | A quoted phrase matches exactly. |
postg* | Prefix match. |
postgres OR mysql | Alternation. |
backup NOT tape | Exclusion. |
FTS5 tokenises words, which means it cannot find a URL, a code fragment, a partial identifier or a piece of punctuation. That is what vault_grep is for: a regular expression over the raw text of every note, slower and unranked, correct when note_search is the wrong shape of tool.
embeddings table so that enabling semantic search later is a data migration rather than a schema one. Until then note_related is the cheap stand-in: shared tags weighted so that rare tags count for more, links in both directions, and co-citation. It is not semantic, but it answers the question that matters before writing — which note about this already exists.
Authentication
OAuth 2.1 with dynamic client registration and PKCE (S256), the same implementation as aegis. The client discovers everything it needs from the two well-known documents and registers itself; you type a password into an HTML login page served by the container, and nothing else is configured on either side.
| Endpoint | Purpose |
|---|---|
/.well-known/oauth-protected-resource | Tells the client which authorization server protects /mcp. |
/.well-known/oauth-authorization-server | Endpoints, supported grants, PKCE methods. |
/register | Dynamic client registration. |
/authorize | The HTML login page. |
/token | Code exchange and refresh. |
/mcp | MCP over Streamable HTTP, protocol revision 2025-06-18. |
Clients, codes and tokens live in memory only. Restarting the container invalidates every session, which is a real cost and an accepted one: a process that holds credentials to a private knowledge base and writes none of them to disk leaves nothing behind for anyone who later gets the volume.
Hashes and dry runs
Four conventions run through every mutating tool. They are the reason this project has more tools than a naive read-and-write server would need.
content_hash
note_read returns a content_hash alongside the content. note_write — the only tool that replaces a whole note — requires it. If the file changed since you read it, the write is refused with the current hash rather than applied. Every other mutating tool accepts content_hash optionally, and passing it turns a hopeful edit into a checked one.
This matters more here than in a code repository. Another agent, Obsidian on a phone, or a git pull can all have touched the note between the read and the write, and the failure would be a silent revert of somebody else's paragraph.
-> note_write
{ "path": "wiki/postgres.md",
"content": "...",
"content_hash": "sha256:9f2c..." }
<- error
{ "error": "stale_content_hash",
"message": "wiki/postgres.md changed since it was read",
"content_hash": "sha256:41ab..." }
dry_run
Every mutating tool takes dry_run. With it set, nothing is written and the tool returns the unified diff it would have applied. Two tools default it to true, because their blast radius is a whole vault rather than a note: vault_replace and tag_rename. Getting a diff back when you expected a write is the intended experience there.
Trash
Nothing is ever removed. Deletions and overwrites put a timestamped copy in <vault>/.secondbrain/trash/, purged after SECONDBRAIN_TRASH_RETENTION. This is independent of git, and deliberately so: git protects committed history, the trash protects the thirty seconds before somebody notices.
Git
With versioning on, every write is a commit whose message names the tool and the path — note_section_edit: wiki/postgres.md. That gives three tools their material (note_history, note_diff, note_restore) and gives you an answer to "what did the agent actually change last Tuesday" that does not depend on this software at all.
The audit line
One structured line per tool call on stdout: user, vault, tool, path, bytes, duration. Never note content, at any log level, including debug. An audit log of a private knowledge base that quotes the knowledge base is a second copy of it with worse access control.
Section edits
note_section_edit is the tool an agent should reach for most often. It changes a note relative to one of its headings, so a long note can be added to without being read and rewritten — which removes both the token cost and the opportunity to lose a paragraph on the way back.
A section runs from its heading to the next heading of the same or higher level, subsections included. Under ## Connection pooling, a following ### Pool size is part of the section; the next ## Backups ends it.
| Mode | Effect |
|---|---|
append_to_section | Insert at the end of the section, after its subsections. The usual choice for adding a fact to an existing topic. |
prepend_to_section | Insert directly after the heading, before existing content. |
replace_section | Replace the body of the section, keeping the heading itself. |
insert_before_section | Insert immediately above the heading, in the parent section. |
insert_after_section | Insert immediately after the section ends, before the next heading. |
delete_section | Remove the heading and everything under it. The removed text goes to the trash like any other deletion. |
append_to_note | Append at the end of the file. No heading needed. |
prepend_to_note | Insert after the frontmatter and before the first heading. No heading needed. |
With create: true a missing heading is added rather than reported, at the end of the note. Without it, editing a section that does not exist is an error — which is usually the more useful answer, because it means the agent's model of the note is out of date.
{
"path": "wiki/postgres.md",
"mode": "append_to_section",
"heading": "Connection pooling",
"content": "PgBouncer in transaction mode; session mode\nholds a backend per client and defeats the point.",
"create": true,
"content_hash": "sha256:9f2c...",
"dry_run": false
}
All 34 tools
The full surface, grouped the way the server groups it. Arguments marked * are required; everything else has a default. A read-only user is offered only the tools marked read — the writing tools are absent from tools/list entirely.
Every tool except vault_list and vault_create takes an optional vault string in addition to the arguments listed below. It is added to each schema by the server rather than declared per tool, which is why it is documented here once. Omitting it uses SECONDBRAIN_DEFAULT_VAULT.
Discovery
vault_list read
List the vaults available to you with their note count, size and last change. Call this first if you do not know which vault the user means.
no arguments
vault_create writes
Create a new vault and populate it with a starting structure. The chosen layout also writes the instructions file that is sent to the client on connect.
name* string · layout wiki-raw | zettelkasten | para | empty
vault_stats read
Counts and health signals: notes, words, tags, links, broken links, orphaned notes and open tasks. Useful before a cleanup, or to decide whether a search came back empty because nothing matched or because the vault is empty.
no arguments
note_list read
List notes by directory or glob, newest first by default. This is directory browsing, not search.
prefix string · glob string · sort string · limit integer · offset integer
note_search read
Full-text search across a vault, ranked, with a snippet per hit. FTS5 syntax. The tool to reach for before creating any note.
query string · tags string[] · prefix string · glob string · modified_after string · limit integer · offset integer
{
"query": "\"connection pool\" OR pgbouncer",
"tags": ["ops"],
"prefix": "wiki/",
"modified_after": "2026-01-01",
"limit": 10
}
vault_grep read
Regular expression search over the raw text of every note, with surrounding lines. Full-text search tokenises words, so it cannot find a URL, a code fragment, a partial identifier or a piece of punctuation; this can. Slower than note_search, and meant as a second resort rather than a first.
pattern* string · case_sensitive boolean · prefix string · context integer · limit integer · include_content boolean
Reading
note_read read
Read a note. Returns the content, the parsed frontmatter and the content_hash that note_write requires. Pass a heading to read only that section, which is much cheaper than reading a long note in full.
path* string · heading string · with_metadata boolean
-> { "path": "wiki/postgres.md", "heading": "Connection pooling" }
<- {
"path": "wiki/postgres.md",
"content": "## Connection pooling\n\nPool size is per process...",
"content_hash": "sha256:9f2c...",
"frontmatter": { "title": "PostgreSQL", "tags": ["database","ops"] }
}
note_outline read
The shape of a note without its text: frontmatter, heading tree, tags, link counts and size. Read this first when a note might be long — it costs a fraction of note_read and tells you which section you actually want.
path* string
note_backlinks read
Which notes link to this one, which notes it links to, and which of its links point at nothing. The fastest way to understand where a note sits in the vault.
path* string
note_related read
Notes that are probably about the same thing, scored by shared tags (weighted so that rare tags count for more), by direct links in either direction, and by co-citation. Use it before writing a new note to find the one that already exists.
path* string · limit integer
tag_list read
Every tag in the vault with the number of notes carrying it, most used first. Frontmatter tags and inline #tags are merged.
prefix string · limit integer
Writing
note_create writes
Create a new note. Fails if one already exists at that path unless overwrite is set — which is the point: search first, and let the failure catch the duplicate you were about to make. Frontmatter with title, tags and created is written for you.
path* string · title string · content string · tags string[] · overwrite boolean · dry_run boolean
note_write writes
Replace the entire body of an existing note. Requires the content_hash from note_read, so an edit made by somebody else in between cannot be silently lost. Prefer note_edit or note_section_edit whenever you are changing part of a note rather than all of it.
path* string · content* string · content_hash* string · keep_frontmatter boolean · dry_run boolean
note_edit writes
Replace an exact string. old_string must match the file character for character, including indentation, and must be unique unless replace_all is set — if it appears more than once the edit is refused rather than guessed. The safest way to change a sentence.
path* string · old_string* string · new_string* string · replace_all boolean · content_hash string · dry_run boolean
{
"path": "wiki/postgres.md",
"old_string": "Pool size is per process, not per host.",
"new_string": "Pool size is per process, not per host, and pgbouncer\ncounts as one process per pool.",
"dry_run": true
}
note_section_edit writes
Change a note relative to one of its headings, in one of eight modes. This is how to add to a note without reading and rewriting it.
path* string · mode* string · heading string · content string · create boolean · content_hash string · dry_run boolean
note_frontmatter writes
Change a note's metadata without touching its body: set fields, add or remove tags and aliases. Use this rather than rewriting the note when all you want is a tag.
path* string · set object · unset string[] · add_tags string[] · remove_tags string[] · add_aliases string[] · content_hash string · dry_run boolean
{
"path": "wiki/postgres.md",
"set": { "status": "reviewed" },
"add_tags": ["ops", "database"],
"remove_tags": ["todo"],
"add_aliases": ["pg"]
}
note_move writes
Move or rename a note and, by default, rewrite every link in the vault that pointed at it. Run it with dry_run first to see which notes would be touched — a rename in a well linked vault can reach a surprising number of files.
path* string · to* string · update_links boolean (default true) · overwrite boolean · dry_run boolean
note_delete writes
Move a note to the vault's trash. Nothing is erased: a timestamped copy is kept in the internal trash directory, and with versioning on the change is a commit that can be reverted. Notes still linking to it are reported.
path* string · content_hash string · dry_run boolean
note_from_template writes
Create a note from a file in the vault's templates/ directory, expanding {{title}}, {{date}}, {{time}}, {{datetime}}, {{year}}, {{month}}, {{day}} and {{slug}}. Call it without a template name to list the templates that exist.
template string · path string · title string · tags string[] · dry_run boolean
Capture
daily_note writes
Open the journal note for a day, creating it if needed, and optionally append to it. The path convention is journal/YYYY/YYYY-MM-DD.md in every layout.
date string (default today) · append string · heading string · dry_run boolean
{
"date": "2026-07-30",
"heading": "Log",
"append": "- 14:20 upgraded the reporting box to PG 17"
}
daily_range read
Read the journal notes for a span of days in one call. Built for weekly and monthly reviews, where fetching seven notes individually is seven round trips and a lot of context spent on nothing.
from string · to string · empty boolean (include days with no note)
inbox_capture writes
Write something down without deciding where it belongs. Creates a timestamped note in inbox/ and nothing else. Use it when the user says something worth keeping in passing — filing it properly can happen later, losing it cannot be undone.
text* string · title string · tags string[] · source string
Curation
vault_review read
The maintenance list for a vault: stubs, orphans, broken links, notes untouched for a long time and open tasks. A knowledge base decays quietly; this is how you see it happening.
stale_after string (duration) · limit integer · only string
note_merge writes
Append one note's body into another, then delete the source and rewrite the links that pointed at it. The source's title is kept as an alias on the target, so old [[wiki links]] keep resolving. Use dry_run first.
path* string (target) · from* string (source) · heading string · dry_run boolean
note_split writes
Split a long note into one note per heading at a chosen level, replacing each section in the original with a link to the new note. The cure for a note that has quietly become a directory.
path* string · level integer · dir string · dry_run boolean
Tasks
task_list read
Every - [ ] checkbox in the vault with its file and line number. Turns a pile of notes into a to-do list without a separate task system.
include_done boolean · prefix string · contains string · limit integer
task_toggle writes
Mark a checkbox done or undone. Identify it by line number from task_list, or by its exact text if it is unique in the note.
path* string · line integer · text string · done boolean · dry_run boolean
Refactoring and attachments
tag_rename writes
Rename a tag everywhere in the vault, in frontmatter and inline, or merge it into an existing one. dry_run defaults to true: this rewrites many files at once.
from* string · to* string · dry_run boolean (default true)
vault_replace writes
Find and replace a literal string or regular expression across every note. dry_run defaults to true and returns a diff per affected note, because a vault-wide replace is the single easiest way to damage a knowledge base.
pattern* string · replace* string · regex boolean · prefix string · limit integer · dry_run boolean (default true)
-> { "pattern": "old-domain\\.example",
"replace": "example.com",
"regex": true,
"prefix": "wiki/" }
<- { "dry_run": true, "notes": 23, "diff": "--- wiki/dns.md\n+++ ..." }
attachment_list read
Non-Markdown files in the vault — images, PDFs, exports — with size and which notes reference them.
prefix string · limit integer
attachment_put writes
Write a base64 encoded file into the vault. Only a fixed set of extensions is accepted, and the limit is 32 MiB — a knowledge base is not a file share, and an agent that can write arbitrary bytes into a mounted volume is a bigger problem than the convenience is worth.
path* string · data* string (base64) · overwrite boolean
History
note_history read
The commits that touched a note, newest first, with the tool that caused each one. Available when versioning is enabled.
path* string · limit integer
note_diff read
Diff a note between two revisions, or between a revision and what is on disk now. Use it to answer "what did that edit actually change".
path* string · from string (revision) · to string (revision, default working tree)
note_restore writes
Bring a note back to how it looked at a given revision. The current version is not lost: it becomes the previous commit, and a copy goes to the trash.
path* string · revision* string · dry_run boolean
-> note_history { "path": "wiki/postgres.md", "limit": 3 }
<- [ { "revision": "8c41af2", "tool": "note_write", "ts": "2026-07-30T14:02:11Z" },
{ "revision": "1d9e07b", "tool": "note_section_edit", "ts": "2026-07-28T09:41:55Z" } ]
-> note_diff { "path": "wiki/postgres.md", "from": "1d9e07b" }
-> note_restore { "path": "wiki/postgres.md", "revision": "1d9e07b", "dry_run": true }
read_file and write_file would be smaller and would push every decision about how to change a note onto the model. Each tool here exists because it makes one specific way of damaging a knowledge base unavailable.