Vaults
A vault is a folder of markdown notes that LatchAI treats as a knowledge graph
rather than a pile of files: one note per thing, [[wikilinks]] between them, and a
type: in the frontmatter that decides which folder the note lives in.
A vault is a mount, not part of the
workspace, and a mount is a vault if and only if its root holds a vault.json.
That is the whole detection rule — explicit opt-in, no heuristics, and nothing happens
to a mount that has not asked. You read and edit a vault in the
Brain area; this page is the contract underneath it.
Two rules shape everything else:
- One note per entity, ever. A second note about the same thing is the failure the whole design exists to prevent.
- Agents never write history. They may search, read, propose a new note into the inbox, and append one dated line to a note’s Log. There is no general write, so “the agent edited what I recorded last month” is not a policy an agent could violate — it is a call that does not exist.
Making one
Section titled “Making one”From the Brain area: + New vault… in the Notes toolbar, in the empty state, or at the bottom of the Graph lens’s scope picker. The form asks for a name, a folder (prefilled beside your existing vault, and it stops following the name the moment you edit it by hand), a schema, and whether to start a git repository.

The New vault form: a name, a folder, one of three schemas, and whether to start a git repository. Three schema choices:
- Standard sends nothing, so the engine’s own default type set is used — people, orgs, projects, systems, decisions, meetings, lessons, glossary.
- Copy another vault reads that vault’s schema at submit time and sends it minus
its
name. - Minimal is the deliberate opposite:
{"types": {"note": {"folder": "notes"}}}— one type and an inbox, nothing else.
The same thing over HTTP:
curl -X POST http://127.0.0.1:7777/api/vaults \ -H 'content-type: application/json' \ -d '{"name": "vault", "path": "~/notes/vault"}'One call does all of it:
- Writes
vault.jsonwith the full resolved schema — the built-in defaults with your overrides merged in — so the file on disk says everything the engine will enforce and nothing is left implicit. - Creates the inbox and a folder for every type that has one.
- Generates a
vault-conventions.mdfrom that schema: the same rules, written out for the people and models who read the folder rather than the JSON. - Runs
git init -b mainand makes a first commit, unless you passed"git": false. - Mounts it — watcher re-armed,
${mounts}-bound MCP servers reconnected, listed in the Brain area immediately.
Point it at a folder that already holds a vault.json and it adopts it instead:
nothing written, no commit, just mounted.
Every refusal is decided before the first write, so a rejected request leaves nothing on disk: a name or a path already mounted answers 409, a path that is a file or a schema whose folders would escape the root answers 400.
The reply carries what you need to check afterwards:
{ "name": "vault", "path": "/Users/you/notes/vault", "noteCount": 0, "inboxCount": 0, "created": true, "git": { "initialized": true, "committed": true }}The nested-repo case
Section titled “The nested-repo case”A vault created inside an existing git repository gets no repo of its own and no commits, and says why:
{ "created": true, "git": { "initialized": false, "committed": false, "warning": "vault is inside the repository at /Users/you/code; triage commits are disabled unless the vault root is a repository toplevel" }}It is still a perfectly good vault. It just cannot have triage commits, because
git add -A in a subdirectory would stage the whole enclosing repo. Move it out if
you want the history. The warning appears once in the UI and can be dismissed; the
same applies to a re-arm failure on the file watcher, which is reported in the
top-level warning.
vault.json
Section titled “vault.json”Every field overrides the matching built-in default. One field behaves differently:
types replaces rather than merges — send a types block and it is the whole type
set, not an addition to the built-in one.
| Field | Default | What it does |
|---|---|---|
name |
the mount name | Display name. |
inboxDir |
"inbox" |
The staging folder — the only place an agent may create a note. |
appendSection |
"Log" |
The heading (without ##) that append_log appends under. |
agentForbidden |
["daily", "vault-conventions.md", "vault.json", "glossary.md"] |
Vault-relative paths agents may not touch. A bare name matches the file and everything under it, so daily fences the whole folder. |
requiredFrontmatter |
["type", "updated", "source"] |
Keys every note must carry. |
types |
the eight below | Type name → where it lives and what it requires. |
Each entry in types:
| Key | Meaning |
|---|---|
folder |
Folder under the vault root. null means the type has no folder of its own — proposals stay in the inbox until a person merges them somewhere by hand. |
datePrefixed |
Filenames are YYYY-MM-DD-<name>.md. For events rather than entities. |
immutable |
A record, not a living note: append_log refuses, forever. |
status |
The allowed status: values. Absent means this type carries no status at all, and a note that declares one fails validation. |
extraRequiredFrontmatter |
Keys this type needs on top of requiredFrontmatter. |
color |
Any CSS colour, passed through unread by the engine. The Brain draws that type in it; a type that says nothing falls back to the UI’s own palette. |
A malformed vault.json costs the override, never the vault: it is logged and the
built-in defaults stand.
The default schema, in full
Section titled “The default schema, in full”This is what POST /api/vaults writes when you send no schema. A vault that agrees
with all of it could equally hold a vault.json of {}.
{ "name": "vault", "inboxDir": "inbox", "appendSection": "Log", "agentForbidden": ["daily", "vault-conventions.md", "vault.json", "glossary.md"], "requiredFrontmatter": ["type", "updated", "source"], "types": { "person": { "folder": "people" }, "org": { "folder": "orgs" }, "project": { "folder": "projects", "status": ["active", "paused", "done", "dropped"] }, "system": { "folder": "systems", "status": ["production", "active", "deprecated"] }, "decision": { "folder": "decisions", "datePrefixed": true, "immutable": true, "status": ["proposed", "accepted", "superseded"], "extraRequiredFrontmatter": ["date"] }, "meeting": { "folder": "meetings", "datePrefixed": true, "extraRequiredFrontmatter": ["date"] }, "lesson": { "folder": "lessons" }, "glossary": { "folder": null } }}A note is plain markdown with a frontmatter block:
---type: personupdated: 2026-09-07source: "agent-session-2026-09-07 (bd-analyst)"aliases: [Jane, JS]---
# Jane Smith
Head of platform at [[acme-corp]], and the owner of [[deploy-pipeline]].
## Facts
- Took over the platform team in March (intro call, 2026-03-04)
## Log
- 2026-09-07 — asked for a security review before the migration (agent-session-2026-09-07 (bd-analyst))What the engine enforces:
- File names are kebab-case —
jane-smith.md,deploy-pipeline.md— optionally with aYYYY-MM-DD-prefix when the type isdatePrefixed. A date prefix a model wrote by hand is stripped first, so a note never ends up with two. - The declared type is authoritative. The folder follows the type, not the other way round.
- Missing frontmatter is completed, never overwritten. A
source:the author wrote by hand is better provenance than one the engine mints, so only the keys that are absent are filled in. Values containing a comma or a colon are re-quoted on the way out, sosource: "Mike, 2026-09-05"round-trips as it was written. - Type and status are validated rather than repaired. An unknown type is refused
with the list this vault actually has; a
status:outside the type’s list, or anystatus:on a type that declares none, is refused too. - Nothing above the Log is rewritten.
append_logadds one bullet, bumpsupdated:, and returns the rest of the file byte for byte.
The frontmatter reader is a deliberate subset of YAML: key: value, key: [a, b],
and block lists. aliases: is the one list every entity note tends to carry, and it is
what lets [[Jane Smith]], [[jane-smith]] and [[JS]] all land on one file.
How wikilinks are indexed
Section titled “How wikilinks are indexed”[[target]], [[target|display]] and [[target#heading]] are all recognised, and the
target is matched on a normalized key: case-insensitive, kebab-ized, .md dropped. So
Jane Smith, jane-smith.md and people/Jane_Smith are one query, which is what lets
a model write the name and still hit the file.
Code is skipped. Fenced blocks and inline spans are masked out before the sweep, so
the [[wikilinks]] in a conventions guide, or a note template inside a fence, stay prose
about the syntax rather than becoming links. Obsidian ignores links inside code; so does
LatchAI.
Claims are resolved in two tiers. A note’s file name and declared aliases claim a
key first; its title and path claim second, so a real file name always wins a
collision with somebody else’s # heading. Backlinks and unresolved links are derived on
every index build rather than stored, and an unresolved link is kept rather than discarded
— it is the vault’s backlog, drawn as a ghost node in the Brain’s graph lens.
Each vault is its own knowledge base. A wikilink never resolves outside it, and an agent binds to exactly one.
The inbox workflow
Section titled “The inbox workflow”Agents propose. Humans decide. The daemon commits.
- An agent calls
vault propose. The note is written to<inboxDir>/and nowhere else, after a mandatory duplicate sweep over names and aliases, the inbox included. A collision is refused with the path that already exists and a pointer toappend_log. - It lands as an ordinary agent write — a
file.writtenevent, so it rides checkpoints and the review gate exactly as a memory write does. - You accept or reject it in the Brain area’s Inbox lens, or from the triage bar in the editor header wherever the note is open.
Accepting moves the file into its type’s folder (or a folder you pick) and commits
vault: accept <name>; rejecting deletes it and commits vault: reject <name>. This
is the only place the daemon runs git in a vault — agents never commit, and nothing
they write leaves the inbox unreviewed.
A type whose folder is null has nowhere to be accepted to. Accept refuses and
says so: merge it by hand, then reject to clear the inbox — or pass an explicit folder.
Two things are reported rather than swallowed. A duplicate is flagged before you accept,
checked over names and aliases in both directions. And if the file moved but the commit
did not, the reply carries committed: false with the reason.
Refiling
Section titled “Refiling”A note can change what it is. Give it a new type, a new name, or both, and the engine does the parts that are easy to forget:
- It moves to the new type’s folder, gaining or losing the
YYYY-MM-DD-prefix as that type requires. typeandupdatedare rewritten; adatea date-prefixed type needs is filled in.- A
statusthe new type does not define is dropped — a status that is not in the new list is not a status. - The old file name is kept as an alias, so every
[[wikilink]]pointing at it still resolves. That is one line of frontmatter instead of an edit to every neighbour, which is why nothing else in the vault is rewritten. - Every other line the author wrote comes out byte for byte: refiling edits the named keys in place rather than re-serializing the block.
A note still in the inbox is retyped where it is — triage is a separate gesture with its own button. A refile onto a path that already exists answers 409 rather than overwriting. Like accepting a proposal, it is a human gesture with a commit behind it, and the bar in the editor previews the target path, the alias it will add and the status it will drop before you press Apply.
Refile is human-only. Like accept and reject, it is not on the vault tool: deciding
what a thing is would let an agent quietly rewrite the shape of the vault.

Refiling a note: a new type and name, the target path, and the alias that keeps old wikilinks resolving.
The vault tool
Section titled “The vault tool”An agent opts into a vault with one line of frontmatter:
---name: bd-analystdescription: Keeps the client knowledge base current.vault: vault # a mounted knowledge vault it may read + propose to---<the system prompt body>vault: names a mount whose root is a vault. Set it and the agent gets the vault
tool plus a compact index of what the vault holds; leave it out and it gets neither —
no tool, no tokens. An agent with a tools: allowlist does not need to list vault:
naming a vault adds it. One vault per agent.
The tool is command-multiplexed, like memory:
| Command | Arguments | What it does |
|---|---|---|
search |
query, limit (default 20) |
Ranked hits over names, aliases, titles and body text. An exact name beats a partial one, which beats a title match, which beats body hits. |
read |
note |
One note’s raw text, by name, alias, title or vault-relative path. |
links |
note |
Its outbound links, its backlinks, and its unresolved links — the last labelled as notes worth writing. |
propose |
name, type, content |
Stages one new note in the inbox. |
append_log |
note, entry |
Adds one dated, attributed bullet under ## <appendSection> and bumps updated:. |
That is the whole surface. append_log refuses on an immutable type and on any path
in agentForbidden; propose refuses an unknown type, a name that is not kebab-case,
frontmatter that fails validation, and any name or alias that already exists.
The system prompt an agent with a vault receives states the same rules in four lines —
search before proposing, new observations go in the Log, genuinely new knowledge goes to
the inbox, never edit history — followed by counts per type and as many one-line entity
summaries as fit under a 4 KB cap, inbox notes last. The rest are reachable through
vault search, which is what the truncation line says.
Quick capture
Section titled “Quick capture”POST /api/vaults/<vault>/capture with {"text": "…"} runs one model pass whose entire
tool registry is the vault tool, bound to that vault. It searches for every entity the
text mentions, appends to what exists, proposes what does not, and returns a short
report plus the vault-relative paths it wrote.
It cannot read files, run shell, or reach the network, whatever the model decides it would like to do, and everything it writes lands in the inbox for you to triage. It is capped at 15 turns — a runaway backstop, not a work budget. See the Brain for the UI on top of it.
HTTP API
Section titled “HTTP API”The vault name is always the first path segment, matched before the literal sub-paths,
so a vault whose mount is called search cannot shadow one of them.
| Endpoint | Body / query | Notes |
|---|---|---|
GET /api/vaults |
— | Every mounted vault: name, path, noteCount, inboxCount. |
POST /api/vaults |
{name, path, schema?, git?} |
Create or adopt, then mount. 201. |
GET /api/vaults/:vault/index |
— | The resolved schema plus every note’s metadata, outbound links, backlinks and unresolved links. One payload; the whole UI is derived from it. |
GET /api/vaults/:vault/note |
?path= |
{content, note, meta} — the raw text and its parsed metadata. |
PUT /api/vaults/:vault/note |
?path=, {content} |
Overwrites one note. 404 rather than create — creation decides the folder from the type. |
POST /api/vaults/:vault/notes |
{name, type, content} |
Creates a note straight into its type’s folder. Humans skip the inbox: a person filing a note has already triaged it. |
GET /api/vaults/:vault/search |
?q=&limit= |
The same ranking the tool uses. |
POST /api/vaults/:vault/refile |
{path, type?, name?} |
409 on a collision. Commits. |
POST /api/vaults/:vault/inbox/accept |
{file, folder?} |
file may be a bare name or a full inbox path. Commits. |
POST /api/vaults/:vault/inbox/reject |
{file} |
Deletes and commits. |
POST /api/vaults/:vault/capture |
{text, agent?} |
Returns {report, written}. |
Every path is fenced to the vault root before anything is read or written: an absolute
path, a .. segment, or a symlink that leads out is refused, and the check runs against
the real path through the nearest existing ancestor so a write to a not-yet-created file
is fenced the same way a read is.
Editing a note through this API — or by hand, or from another app — broadcasts the same
fs.changed the workspace watcher emits, so an open editor and the inbox badge follow
along without a refresh.
Obsidian compatibility
Section titled “Obsidian compatibility”A vault is a folder of markdown files and nothing else, so Obsidian (or any editor) can open the same folder and see the same notes.
- Notes are plain markdown with a frontmatter block.
vault.jsonis a sidecar, not a format. [[wikilinks]], including|displayand#headingforms.- Dot-directories are skipped by the index walk —
.obsidian/,.git/,.claude/— as isnode_modules. Obsidian’s own state is not notes. - Links inside code fences and inline spans are ignored, the way Obsidian ignores them.
Limits
Section titled “Limits”| Limit | Value |
|---|---|
| Note size | 512 KB — a larger file is skipped by the index |
| Notes walked per vault | 5,000 |
| Parsed notes cached | 4,000, keyed by path and mtime |
| Vault index in an agent’s prompt | 4 KB, then a “use vault search” line |
search default |
20 hits |
| Quick capture | 15 turns |
| Notes drawn per vault in the provenance graph | 1,000 |
Rebuilding the index on every request is affordable because a note whose mtime has not moved is answered from the parse cache; only the directory walk is repeated.