Skip to content

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.

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: name, folder, schema choice, and git checkbox

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:

Terminal window
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:

  1. Writes vault.json with 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.
  2. Creates the inbox and a folder for every type that has one.
  3. Generates a vault-conventions.md from that schema: the same rules, written out for the people and models who read the folder rather than the JSON.
  4. Runs git init -b main and makes a first commit, unless you passed "git": false.
  5. 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 }
}

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.

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.

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: person
updated: 2026-09-07
source: "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-casejane-smith.md, deploy-pipeline.md — optionally with a YYYY-MM-DD- prefix when the type is datePrefixed. 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, so source: "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 any status: on a type that declares none, is refused too.
  • Nothing above the Log is rewritten. append_log adds one bullet, bumps updated:, 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.

[[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.

Agents propose. Humans decide. The daemon commits.

  1. 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 to append_log.
  2. It lands as an ordinary agent write — a file.written event, so it rides checkpoints and the review gate exactly as a memory write does.
  3. 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.

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.
  • type and updated are rewritten; a date a date-prefixed type needs is filled in.
  • A status the 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.

The refile bar in the note editor header

Refiling a note: a new type and name, the target path, and the alias that keeps old wikilinks resolving.

An agent opts into a vault with one line of frontmatter:

---
name: bd-analyst
description: 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.

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.

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.

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.json is a sidecar, not a format.
  • [[wikilinks]], including |display and #heading forms.
  • Dot-directories are skipped by the index walk — .obsidian/, .git/, .claude/ — as is node_modules. Obsidian’s own state is not notes.
  • Links inside code fences and inline spans are ignored, the way Obsidian ignores them.
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.