A local-first agent brain. Tusk turns a directory of markdown files into a schema-validated, semantically-indexed graph — queryable from the CLI and from any MCP-compatible agent (Claude Code, Cursor, etc.).
Files are the source of truth. Git is the history. Tusk is the indexer and the retrieval engine.
markdown vault ──▶ tusk indexer ──▶ SQLite graph + embeddings
│
├─▶ CLI (tusk query, tusk node …)
└─▶ MCP tools (tusk_query, tusk_node_create, …)
- Local first. No service to log in to. The index lives in
.tusk/next to your files. - Schema-validated. Node and edge types are declared in
tusk.toml. Off-schema content is warned, never rejected. - Structural + semantic. A compact filter grammar for the graph (
key=value/key:value, ranges, edge traversal, boolean composition), Ollama-backed embeddings for similarity, and a hybrid mode that filters then ranks. - External edits are first-class. Vim, Obsidian, an LLM piping markdown — they all work; the watcher keeps the index live.
- One engine, two surfaces. Every read/write graph verb has a 1:1 MCP tool; workspace bootstrap (
tusk init) and the graph viewer (tusk graph) stay CLI-only.
- (Optional, for semantic search) Ollama running locally with an embedding model, e.g.
ollama pull nomic-embed-text.
curl -fsSL https://raw.githubusercontent.com/germanamz/tusk/main/install.sh | shDetects your OS/arch, downloads the latest GitHub release, drops the tusk binary into ~/.local/bin (override with INSTALL_DIR=/usr/local/bin), and installs its man pages into ~/.local/share/man (override with MAN_DIR; man tusk works once that dir is on your MANPATH). Pin a specific release with TUSK_VERSION=v1.1.0. Prebuilt archives ship for darwin/linux/windows on amd64 + arm64.
Requires Go 1.26+.
git clone https://github.com/germanamz/tusk
cd tusk
make build
# binary at ./bin/tusk — move it onto your PATH
install bin/tusk /usr/local/bin/tuskOr, without cloning:
go install github.com/germanamz/tusk/cmd/tusk@latestVerify:
tusk --versionTusk has no self-update command — re-run whichever install method you used and the binary gets overwritten in place. Your workspace and .tusk/ index are untouched.
# Prebuilt binary — same one-liner; defaults to the latest release
curl -fsSL https://raw.githubusercontent.com/germanamz/tusk/main/install.sh | sh
# Pin a specific version
curl -fsSL https://raw.githubusercontent.com/germanamz/tusk/main/install.sh | TUSK_VERSION=v1.2.0 sh
# From source
cd tusk && git pull && make build && install bin/tusk /usr/local/bin/tusk
# go install
go install github.com/germanamz/tusk/cmd/tusk@latestAfter a major upgrade, run tusk reindex to pick up any indexer changes, then tusk doctor to confirm the workspace is healthy.
For per-command reference (flags, examples), see docs/cli/. For multi-command recipes, see docs/cli/workflows.md. Man pages are in
man/—man -M man tuskafter cloning.
# 1. Initialize a workspace in the current directory
mkdir my-brain && cd my-brain
tusk init --name my-brain
# 2. Add a built-in type pack so you have some node types
tusk pack add vault # note, meeting, decision + references/relates-to edges
tusk pack add tags # tag node type + tagged edge
tusk pack add kanban # ticket node type + workflow + parent/blocks edges
# 3. Create a node
tusk node create --path notes/hello.md --type note --title "Hello, Tusk"
# 4. Build the index (also runs after every CLI write)
tusk reindex
# 5. Query the graph
tusk node list 'type=note'
tusk query 'type=ticket status=active' --sort '+priority,-due'
# 6. Get a quick health check
tusk status
tusk doctorA Tusk workspace is just a directory:
my-brain/
├── tusk.toml # workspace manifest (committed)
├── .tusk/ # gitignored — local SQLite index
│ └── tusk.db
├── .gitignore
├── notes/
│ └── auth-rfc.md
├── tickets/
│ └── fix-login-bug.md
└── tags/
└── auth.md
Every .md file with a type: field in YAML frontmatter is a node. The file path (minus the extension) is the canonical node id — no separate id field.
---
type: ticket
title: Fix login bug
status: active
priority: high
due: 2026-05-15
parent: tickets/auth-epic
blocks: [tickets/refactor-storage]
tags: [auth, security]
---
# Fix login bug
The bug occurs when users with SSO accounts hit the password reset flow.
See [[notes/auth-rfc]] for context.typeis the only universally reserved key.- Other frontmatter keys are either properties (string / int / date / enum / ref / list-of) or edges (declared in
tusk.toml). [[notes/auth-rfc]]body wikilinks materialize as edges to that node id for any edge type declared withwikilinks = true(e.g. thevaultpack'sreferencesedge). The Obsidian aliased form[[notes/auth-rfc|the auth RFC]]links to the same node id — the text after|is display only — and atusk node moveretargets the id while keeping the display text.
Edges are typed, declared in the manifest, and can be created two ways:
- Frontmatter — the natural place.
parent: tickets/auth-epicdeclares aparentedge. - CLI / MCP —
tusk edge add --type blocks --source tickets/a --target tickets/b.
Edge declarations enforce legality (from/to types), cardinality, ordering, and optional acyclic = true (cycles are rejected at write time).
tusk.toml is the contract between you and the engine. A minimal manifest:
[workspace]
name = "my-brain"
ignore = ["bin/", "node_modules/", "*.test"]
[embeddings]
provider = "ollama"
endpoint = "http://localhost:11434"
model = "nomic-embed-text"
dim = 768
[node-types.note]
description = "A free-form markdown note"
properties = []
[node-types.decision]
description = "A captured decision"
properties = [
{ name = "decided-at", type = "date", required = true },
{ name = "status", type = "enum", values = ["proposed", "accepted", "rejected", "superseded"] },
{ name = "supersedes", type = "ref", to = "decision" },
]
[edge-types.references]
description = "Implicit edge materialized from body wikilinks"
from = ["*"]
to = ["*"]
cardinality = "many-to-many"
inverse = "referenced-by"ref properties auto-materialize edge types of the same name — declaring supersedes as a ref to decision gives you a supersedes edge for free.
Edited tusk.toml while a daemon is running? tusk reload (or the tusk_reload MCP tool) re-reads and validates the manifest, hot-swaps the schema in place — no restart — and converges any sibling daemons via the .tusk/manifest-epoch sentinel. It then reindexes to re-validate your content against the new schema. Validation matches startup, so a reload lands the same state a restart would.
Instead of declaring everything by hand, tusk pack add <name> splices a curated TOML block into your manifest:
| Pack | Adds |
|---|---|
vault |
note, meeting, decision; references (wikilinks) + relates-to |
tags |
tag node + tagged edge (with tags: [a, b] frontmatter shorthand) |
kanban |
ticket node with workflow-validated status; parent (WBS) + blocks edges |
dev |
spec, plan, handoff, package — dogfooding pack for tracking software projects |
Packs compose: add vault + tags + kanban and you have notes, decisions, tags, and a kanban workflow on top.
tusk pack add vault
tusk pack add tags
tusk pack add kanbanYou can also load a pack from a URL or local file:
tusk pack add https://example.com/packs/research.toml
tusk pack add file://$PWD/my-pack.tomlThe index lives in .tusk/tusk.db (SQLite + WAL). It is derived state — delete .tusk/ and tusk reindex rebuilds it identically.
tusk reindex
# Reindex done: 142 indexed, 0 removed, 3 skippedreindex walks the workspace, parses every .md, validates frontmatter against the manifest, resolves refs + wikilinks into edges, and enqueues embeddings.
tusk watchRuns fsnotify against the workspace and applies edits incrementally. Drains the embed queue in the background.
- Every
.mdfile with atype:field, anywhere in the workspace. - Every
.html/.htmfile with a<meta name="tusk:type">tag — indexed over its prose (tags stripped, entities decoded), with<meta name="tusk:*">becoming typed node properties anddata-*attributes captured as lenient signals under the reserveddatakey. - Filtered through
.gitignore+[workspace] ignorepatterns. .tusk/and.git/are always ignored.
Off-schema content is warned, not rejected — a file with an unknown type: or a property violation still gets indexed (so it stays queryable) and surfaces in tusk doctor.
A compact filter grammar that compiles to parameterized SQL. Property
comparisons accept = or : interchangeably; the rest of the grammar uses
the operators below.
# Property predicates (`=` and `:` are equivalent)
tusk query 'type=ticket status=active priority=high'
tusk query 'type:plan shipped-at>=2026-04-01' # date ordering, chronological
# Edge traversal: -> outgoing, <- incoming
tusk query 'type=ticket blocks->type=ticket' # tickets that block other tickets
tusk query 'type=note <-references type=spec' # notes referenced by specs
# Multi-hop
tusk query 'type=ticket parent->parent->title="auth-epic"'
# Sort + pagination
tusk query 'type=ticket status=active' --sort '+priority,-due' --take 10Requires [embeddings] configured (Ollama by default). Embedding runs asynchronously after writes; until a node is embedded, it's invisible to semantic queries (and surfaces in tusk doctor).
tusk query 'type=*' --semantic "auth bug in password reset flow" --take 5Structural filter narrows the candidate set; semantic similarity ranks within it.
tusk query 'type=ticket status=active' --semantic "login flow" --take 10Add --json to any query for structured output that's easy to pipe into an LLM or a script.
tusk query 'type=decision' --semantic "storage backend" --top 3 --jsontusk mcp runs an MCP server backed by the same indexing engine as the CLI, exposing the graph verbs as tools. Agents should prefer these tools over shelling out to tusk — they run in the warm daemon with the index already open. Stdio is the default transport; SSE is available on a port.
tusk mcp # stdio (for Claude Code / Cursor / Codex)
tusk mcp --transport sse --addr :8765It holds the workspace open for the lifetime of the session: a single SQLite handle, an embed-queue drainer, and an fsnotify watcher all live in the same process so the index stays warm across tool calls.
claude mcp add tusk -- /usr/local/bin/tusk mcpOr directly in ~/.claude.json:
{
"mcpServers": {
"tusk": {
"command": "/usr/local/bin/tusk",
"args": ["mcp"],
"cwd": "/path/to/my-brain"
}
}
}| Tool | What it does |
|---|---|
tusk_status |
node counts by type, edge count, queue depth, last reindex |
tusk_doctor |
validation warnings, dangling refs, embed-queue retries |
tusk_node_get / tusk_node_list |
read by id or filter |
tusk_node_render |
render a node's content as plain text (HTML tags / markdown markup stripped) |
tusk_node_create / tusk_node_modify / tusk_node_move / tusk_node_delete |
write |
tusk_edge_add / tusk_edge_remove / tusk_edge_list |
edge CRUD |
tusk_query |
structural + optional semantic ranking |
tusk_context |
composed warm-context digest (pinned nodes, recent activity, aliases) |
tusk_run |
invoke a manifest-declared alias by name |
tusk_reindex |
force a full walk |
tusk_reload |
hot-reload tusk.toml: validate + swap the schema, no restart |
tusk_reset |
drop and rebuild the index from files (confirm: true) |
tusk_pack_add |
merge a built-in type pack's node/edge types into tusk.toml and hot-reload the schema |
Workspace bootstrap (tusk init) and the graph viewer (tusk graph) stay CLI-only.
tusk status # node counts, edge count, embed-queue depth, last reindex timestamp
tusk doctor # validation warnings, dangling refs/wikilinks, embed-queue errors, embed statsdoctor is the place to look when:
- semantic queries seem to be missing nodes → check the embed-queue depth and last error
- a wikilink points to nothing → dangling-ref warning surfaces it
- a manifest change just landed → re-validate every affected node
Single Go binary, single SQLite index, single embedding provider (Ollama for now).
flowchart TD
workspace["Workspace<br/>(markdown + tusk.toml)"]
engine["Engine (cmd/tusk + internal/*)<br/>manifest · node · edge · reindex · filter · embed<br/>watcher · behaviors · mcp"]
db[".tusk/tusk.db<br/>(SQLite WAL, embeddings table)"]
workspace -->|"fs walk / fsnotify"| engine
engine -->|"reads / writes"| db
- Filesystem > index, always. The index is a cache; if it is stale, wedged, or corrupt, run
tusk reset(or thetusk_resetMCP tool withconfirm: true) to drop and rebuild it from your files. The markdown files are the source of truth, so nothing is lost. - Stateless across machines. Clone the vault, reindex, get an identical brain.
- Single-writer, many-readers. SQLite WAL + a workspace-wide advisory lock so
tusk mcpand one-shot CLI calls coexist.
Product vision and design principles live in PRODUCT.md. Per-package notes live in docs/packages/.
make build # ./bin/tusk
make test # unit tests
make test-race # with race detector
make vet
make lint # golangci-lint
make fmtSee STYLE.md for the codebase conventions and CONTRIBUTING.md for how to propose changes.