Read tools
Read tools
Every read tool. Each section gives the input schema, return shape, an example call, and the errors you might see.
A server instance serves a single vault — the one Lettuce.app has open, or the path passed to lettuce serve. Tools don’t take a vault argument; paths are always vault-relative.
get_vault_info
Vault root, note count, index time, the AGENTS.md brief, and immutable prefixes — plus what you can do and whether work is waiting. Call it first on connect.
Input: {} (no arguments)
Returns:
{
"root": "/Users/you/Notes",
"noteCount": 142,
"indexedAt": "2026-05-28T09:12:34Z",
"agentsFile": "…the vault's AGENTS.md brief, or null…",
"immutablePaths": ["sources/"],
"capabilities": {"embeddings": true, "llm": true, "hasIndexCatalog": true, "proposeOnly": false},
"liveness": {"pendingDirectives": 2, "lastLogDate": "2026-05-27", "embeddingIndexStale": false}
}
capabilities tells you which tools will work (embeddings gates semantic_search/answer/related_notes; llm gates answer/lint_semantic/wiki_maintain; proposeOnly means your writes queue as proposals). liveness tells you whether there’s work waiting (pendingDirectives > 0 → check pending_directives) and whether retrieval is current (embeddingIndexStale). See [[Agent reference/05 Write tools|immutablePaths]] for the write-once rule.
list_notes
List notes in the vault.
Input:
{
"path": "Inbox", // optional folder (vault-relative); defaults to vault root
"recursive": true, // optional, default true
"limit": 50, // optional
"offset": 0 // optional
}
Returns: a paginated list of {path, name, title, tags, modified, size}:
{
"items": [
{"path": "Inbox/random thought.md", "name": "random thought",
"title": "Random thought", "tags": [],
"modified": "2026-05-28T09:12:34Z", "size": 412}
],
"total": 142,
"offset": 0,
"limit": 50
}
Errors: none for a valid call (an out-of-tree path is rejected with path-invalid).
read_note
Read the full content of a single note.
Input:
{"path": "Inbox/random thought.md"}
Returns:
{
"path": "Inbox/random thought.md",
"name": "random thought",
"title": "Random thought",
"tags": ["..."],
"frontmatter": {"title": "...", "tags": [...]},
"content": "---\ntitle: ...\n---\n\n# ...",
"outgoingLinks": [{"target": "Other note", "kind": "wikilink", "resolvedPath": "Other note.md"}],
"stat": {"size": 412, "modified": "2026-05-28T09:12:34Z", "created": "..."}
}
For surgical, conflict-free edits, prefer [[Agent reference/05 Write tools|apply_edits]] (each find must match exactly once, or the whole call rolls back) over hashing the body yourself.
Errors: note-not-found; a malformed or out-of-tree path (absolute, .., traversal attempt) is rejected with path-invalid.
read_outline
The note’s heading outline — much cheaper than read_note when you only need structure.
Input:
{"path": "Big note.md"}
Returns: a list of {level, title, id}, where id is the slug matching the rendered heading anchor:
[
{"level": 1, "title": "Top", "id": "top"},
{"level": 2, "title": "Section A", "id": "section-a"}
]
Use this to plan edits without reading the entire note. (For outgoing links use list_outgoing_links.)
list_outgoing_links
The wikilinks (and Markdown links) out of a note, resolved or not — cheaper than read_note when you only want the edges.
Input: {"path": "Lettuce.md"}
Returns: a paginated list of {target, displayText, kind, heading, resolvedPath} — kind is "wikilink" or "markdown"; resolvedPath is the vault-relative target, or null if the link is unresolved (a candidate to fix).
list_backlinks
The inverse: every note that links to the given note.
Input: {"path": "Lettuce.md"}
Returns: a paginated list of {sourcePath, sourceName, displayText} — the notes pointing here, and the link text they used.
search
Full-text search across note titles and bodies (SQLite FTS5 / BM25).
Input:
{
"query": "lettuce", // required
"limit": 25 // optional
}
Returns: a paginated list of {path, title, snippet, score}:
{
"items": [
{
"path": "Projects/Lettuce.md",
"title": "Lettuce",
"snippet": "…this is the lettuce project…",
"score": -8.4
}
],
"total": 12,
"offset": 0,
"limit": 25
}
score is the BM25 rank — lower (more negative) = better match — and results come back ordered best-first.
query supports a vetted operator subset:
plain words: implicitly AND’d (each bare word is matched as a prefix)
"exact phrase"in double quotesOR/NOTbetween terms (uppercase)a leading
-on a word to exclude it
There is no field:value / column filtering and no parenthesised grouping — a malformed query simply returns no rows rather than erroring.
semantic_search
Vector search — finds notes by meaning, not just keyword overlap, by embedding the query and ranking notes by cosine similarity. Complements search (lexical/BM25): use search for exact terms, semantic_search for “notes about X”.
Input:
{
"query": "how do agents avoid clobbering each other", // required
"hybrid": true, // optional — fuse vector + keyword (BM25) ranking (reciprocal-rank fusion)
"prefer_recent": true, // optional — down-weight stale pages so fresher ones rank higher (off by default)
"expand": true, // optional — HyDE/paraphrase query expansion for better recall (needs an LLM provider)
"limit": 20 // optional
}
Returns: the same {path, title, snippet, score} page shape as search (here score is the cosine similarity, or the fused score in hybrid mode — higher = better).
The first call embeds the whole vault (a one-time cost), caching vectors in .lettuce/vectors.json; later calls only embed the query. Edits are picked up automatically — a changed note is re-embedded before the next semantic query, so retrieval always reflects your latest writes (no manual refresh needed). Use reindex_embeddings (or lettuce embed) to force a reconcile pass after a large batch — it re-embeds only changed notes and prunes deletions, not a from-scratch rebuild.
Requires an embeddings provider — any OpenAI-compatible /v1/embeddings endpoint (OpenAI, or a local server like Ollama/LM Studio, which needs no key). Configure it under Settings → AI providers, or via LETTUCE_EMBED_BASE_URL / LETTUCE_EMBED_MODEL. With no provider, the tool returns a clear “not configured” error.
answer
Ask the wiki a question and get a grounded, cited answer. It retrieves the most relevant pages (semantic + keyword when an embeddings provider is configured, otherwise keyword/BM25), feeds their bodies to the model, and synthesizes an answer using only those pages — the capstone of the QUERY loop (“ask the wiki”, not “ask the model”). The agent can then file_page the answer to bank it.
Input:
{
"query": "how do agents avoid clobbering each other?", // required
"limit": 6, // optional — how many pages to retrieve as context
"prefer_recent": true, // optional — prefer fresher pages when retrieving context (off by default)
"rerank": true, // optional — rerank a lexical+semantic *fused* candidate pool before
// synthesis (a hosted cross-encoder if LETTUCE_RERANK_URL is set,
// else LLM listwise): more precise top-k, one extra call (off by default)
"expand": true, // optional — HyDE/paraphrase query expansion for better recall (one extra call)
"use_graph": true, // optional — graph prior (boost well-linked notes + notes sharing the top
// hit's tags) + 1-hop expansion of a top page's linked neighbours
"check_faithfulness": true // optional — add a groundedness self-check (one extra model call)
}
Returns:
{
"answer": "Lettuce soft-locks a note while the user is editing it…",
"citations": [{"path": "Concepts/Soft locks.md", "title": "Soft locks"}],
"quotedSpans": [{"path": "Concepts/Soft locks.md", "title": "Soft locks", "quote": "focused in the editor", "heading": "Locks", "ordinal": 2}],
"retrieved": [{"path": "Concepts/Soft locks.md", "title": "Soft locks"}, …]
}
citations are the pages the model said it used, validated against retrieved (it can’t cite a page it wasn’t shown). quotedSpans are short verbatim supporting quotes, each validated to be an actual substring of the text that page contributed — hallucinated or paraphrased quotes are dropped, so you can jump straight to the evidence. retrieved is the full context set, so you can see the “sources”. Grounded in the vault only — if the pages don’t cover the question, the answer says so.
Each quotedSpans entry also carries the heading and ordinal of the passage the quote came from (when retrieved via the passage path), so a reader can jump straight to that section.
With check_faithfulness: true, the response also carries faithfulness: {score, unsupported} — a second pass that splits the answer into claims and judges each against the retrieved passages only. score is the fraction supported (0–1); unsupported lists the claims the sources don’t back. Catches confident-but-unsupported synthesis that citation validation can’t (one extra model call).
Requires an LLM provider configured (Settings → AI providers, or env); returns a clear “not configured” error otherwise. From the shell: lettuce answer <vault> <question…>.
related_notes
Notes semantically nearest to a given note that it isn’t already linked to — a meaning-based “suggest links” across the whole vault (where suggest_links only finds literal name mentions). Use it to thread a new page into the existing graph.
Input:
{"path": "Concepts/Embeddings.md", "limit": 5}
Returns: {path, title, snippet, score} (cosine similarity), already-linked notes and the note itself excluded. Requires an embeddings provider (same setup + error as semantic_search). From the shell: lettuce related <vault> <note-path>.
find_duplicates
Near-duplicate pages — every pair whose embeddings exceed a cosine threshold (default 0.9). Merge candidates for a health pass.
Input:
{"threshold": 0.9}
Returns: [{pathA, pathB, similarity}], highest similarity first. Requires an embeddings provider.
reindex_embeddings
Force-refresh the semantic index so retrieval reflects the latest writes. Re-embeds new/changed notes (both the per-note vectors and the per-passage chunks) and prunes deleted ones.
Input: {} (no arguments)
Returns: {notes, passages} — how many were (re)embedded.
You rarely need this: a changed note is re-embedded automatically before the next semantic_search / answer / related_notes / find_duplicates call, so retrieval already reflects your edits. Call it to force a reconcile pass after a large batch of writes (only changed notes are re-embedded; deletions are pruned), or equivalently run lettuce embed. Requires an embeddings provider.
eval
Score retrieval and answer quality against a fixed question set, so you can tell whether a change (re-chunking, a reranker, a prompt tweak) actually helped. Reads eval/questions.jsonl — one JSON object per line:
{"question": "how do soft locks work?", "expected_paths": ["Concepts/Soft locks.md"], "must_contain": ["focused in the editor"]}
Input: {"k": 6} (optional top-k cutoff)
Returns: {k, count, recallAtK, precisionAtK, mrr, hitRate, faithfulnessRate, perQuestion}. Retrieval-only (recall/precision/MRR/hit-rate) without an LLM provider; with one it also scores faithfulness (the answer cites only retrieved pages and contains each must_contain span). From the shell: lettuce eval <vault> [--k N] [--json].
recent_activity
The log.md ledger as data, plus the “ingested but not yet compiled” work signal — what’s new to act on between CAPTURE and COMPILE.
Input: {"since": "2026-05-01", "op": "ingest", "limit": 20} (all optional — since is a YYYY-MM-DD lower bound, op filters to one ledger op)
Returns: {entries: [{date, op, title}], uncompiledSources} — entries newest-first; uncompiledSources are sources/ notes with no inbound wikilink (captured but not yet digested into a page).
wiki_health
A single 0–100 wiki-health score with its components, so you can track whether the wiki is getting healthier.
Input: {}
Returns: {score, noteCount, linkHealth, lintCleanliness, tagCoverage, orphans, lintWarnings} — linkHealth = 1 − orphan ratio; lintCleanliness = 1 − warnings/notes; tagCoverage = fraction of notes carrying a tag. From the shell: lettuce health <vault> [--json].
topic_map
Cluster the vault by embedding similarity into topics and flag clusters with no hub page — so you can create an index/MOC to thread related notes into the graph.
Input: {"threshold": 0.85, "min_size": 2} (optional — cosine cutoff for “same topic”, minimum cluster size)
Returns: {clusters, missingHubs}; each cluster is {notes, size, hasHub, sharedTags}. missingHubs are the clusters with no member linked-to by ≥2 others — name a new hub from sharedTags and link them in. Requires an embeddings provider.
link_graph
Local link subgraph around a note: BFS to depth hops, following both outbound wikilinks and inbound backlinks. Use it to build a context bundle.
Input:
{
"path": "Lettuce.md", // required
"depth": 2 // optional, default 1, capped at 5
}
Returns:
{
"center": "Lettuce.md",
"depth": 2,
"nodes": [
{"path": "Lettuce.md", "name": "Lettuce"},
{"path": "MCP overview.md", "name": "MCP overview"}
],
"edges": [
{"from": "Lettuce.md", "to": "MCP overview.md"}
]
}
list_attachments
List the non-markdown files in the vault (images, PDFs, data). The note index is markdown-only, so this is how you discover which referenced images actually exist on disk.
Input:
{
"path": "assets", // optional, folder to list under (vault-relative)
"limit": 50 // optional
}
Returns: a paginated list of {path, size, mimeType}:
{
"items": [
{"path": "assets/diagram.png", "size": 18234, "mimeType": "image/png"}
],
"total": 3
}
read_attachment
Read a single non-markdown file (image, PDF, data) as base64 — the “read the note text first, then view its referenced images separately” step. Use it after read_note when a note points at an image you need to actually see.
Input:
{"path": "assets/diagram.png"}
Returns:
{
"path": "assets/diagram.png",
"mimeType": "image/png",
"size": 18234,
"base64": "iVBORw0KGgo..."
}
Oversized files are rejected (the cap protects the context window).
lint
Run the deterministic, offline vault-hygiene check — broken links, orphan notes, and tag issues. This is also where orphans come from (rules orphan-note and no-inbound); there is no separate find_orphans tool.
Input:
{}
Returns: a list of {rule, severity, path, message, suggestion} findings. Rules include unresolved-link, orphan-note (no links in or out), no-inbound (outbound but no inbound), tag-typo, orphan-tag, and untagged-note — plus schema rules (unknown-tag / missing-required-tag / deprecated-tag) when index.md declares a lint: block.
lint_semantic
LLM-assisted health check that goes beyond the deterministic rules: contradictions between pages, stale claims, important concepts that lack their own page, data gaps, open questions the wiki can’t yet answer, and suggested sources worth ingesting next. Advisory — it returns findings; nothing is applied. (The last two are forward-looking: they tell you what to write or read next to keep the wiki compounding.)
Input:
{}
Requires an LLM provider configured (an API key in Settings → AI providers, or via env). With none configured it returns a clear “No LLM provider configured” error.
suggest_tags
LLM tag suggestions for the vault’s untagged / missing-required-type notes (the ones lint flags). Advisory — propose-then-confirm; nothing is applied.
Input:
{}
Returns: a list of {path, suggestedTags, rationale}. Also requires an LLM provider configured (same error as lint_semantic when absent).
agent_mentions
Find @handle mentions across the vault. Pass handle to filter to a single agent’s queue.
Input:
{
"handle": "claude", // optional, case-insensitive; omit for all handles
"limit": 50 // optional
}
Returns: a list of {path, handle, line, context}:
[
{
"path": "Inbox/note.md",
"handle": "claude",
"line": 4,
"context": "Hey @claude, can you organise this?"
}
]
Useful for “did anyone leave me work?” check-ins. The mention syntax is @agent-name; email-like patterns (foo@example.com) are skipped.
pending_directives
Unresolved > [!ai] callouts in the vault — pending tasks left for an agent.
Input:
{
"path": "Projects/Lettuce.md", // optional, scope to one note
"limit": 25 // optional
}
Returns: a list of {path, line, title, body, done}:
[
{
"path": "Projects/Lettuce.md",
"line": 12,
"title": "Review the test plan",
"body": "focus on the concurrency cases",
"done": false
}
]
Do the work, then mark it complete via [[Agent reference/06 Suggestion tools|complete_directive]] (which flips the marker to [!ai-done]).
await_directives
The long-poll companion to pending_directives: instead of returning instantly, it blocks until the vault changes, then returns the current pending directives plus a new cursor. This is how a > [!ai] callout typed in the app reaches a running agent without the user re-prompting — the write bumps the vault’s revision and wakes the waiting call within FSEvents latency.
Input:
{
"cursor": 0, // the cursor you last received; 0 on the first call
"timeout_ms": 25000, // max time to block; clamped to [1000, 55000]
"path": "Inbox", // optional, scope to one folder/note
"worker": "mac/serve",// optional, stable id — enables claim leasing (see below)
"claim": true, // lease returned directives to `worker` (no-op without `worker`)
"limit": 25 // optional
}
An unrelated edit elsewhere in the vault does not wake the poll — only a change to the pending directive set advances the cursor. So a plain note write while you’re parked leaves you blocked; a new or completed [!ai] wakes you.
Returns: {cursor, timedOut, directives} — directives uses the same {path, line, title, body, done} shape as pending_directives:
{
"cursor": 47,
"timedOut": false,
"directives": [
{ "path": "Inbox/raw.md", "line": 3, "title": "Summarize the section above", "body": "", "done": false }
]
}
On timeout it returns { "timedOut": true, "directives": [] } — nothing is lost; just call again. The idle loop is:
cursor = 0
loop:
{ cursor, directives } = await_directives(cursor) # blocks
for d in directives: do the work, then complete_directive(d.path, d.line)
# re-poll with the new cursor — you only wake on fresh changes
Pass the returned cursor back on the next call so you only wake on changes after the ones you’ve seen. The first call (cursor 0) returns any existing backlog immediately. A single worker that finishes each returned batch before re-polling won’t double-process.
Running several workers? Give each a distinct worker id and leave claim on. Each returned directive is then leased to that worker for ~2 minutes, and a directive held by another worker is withheld from your result — so no two workers act on the same one. complete_directive releases the lease immediately; a crashed worker’s lease frees up when the TTL lapses, and your own leases stay visible so you can resume a batch after a reconnect. (Leases are cooperative and in-memory — a trust-domain convenience, not a security boundary.)
list_tags
Every tag in the vault (frontmatter + inline #tag) with how many notes carry it.
Input: {}
Returns: a paginated list of {tag, count}.
notes_by_tag
The notes carrying a tag — or any nested descendant of it ("project" also returns "project/web").
Input: {"tag": "project", "offset": 0, "limit": 50} (tag required; offset/limit optional)
Returns: a paginated list of note summaries {path, name, title, tags, modified, size}.
suggest_links
Unlinked mentions in a note: other notes’ names that appear as plain prose but aren’t [[linked]] yet — deterministic candidates to confirm and link (where related_notes finds semantic neighbours, this finds literal name matches). Ambiguous names (shared by 2+ notes) and very short names are skipped.
Input: {"path": "Projects/Lettuce.md"}
Returns: a paginated list of {targetPath, name, count} — the note that could be linked to, the matched name, and how many times it occurs.
list_folders
All folder (group) paths in the vault, excluding hidden dirs like .trash. Empty groups are listed too.
Input: {}
Returns: a plain array of vault-relative folder paths (["Projects", "Projects/2026", "Inbox"]).
Standard errors
A failure inside a tool call comes back as a normal tools/call result with isError: true and a machine-dispatchable kind (no JSON-RPC code) — branch on kind:
{
"content": [{"type": "text", "text": "Note not found: Inbox/missing.md"}],
"isError": true,
"kind": "note-not-found"
}
Protocol-level failures (bad JSON, unknown method, malformed params) use the JSON-RPC error envelope with a code instead. See Agent reference/08 Errors and rate limits for the full kind catalogue and the two envelopes.