A fast CLI tool for searching, filtering, and querying your Granola meeting notes. Data is synced from the Granola API and stored in a local SQLite database.
cargo install --path .Or build from source:
cargo build --release
# Binary at target/release/grans
# With GPU acceleration for semantic search:
cargo build --release --features directml # Windows (any GPU, recommended)
cargo build --release --features cuda # NVIDIA (requires CUDA toolkit + cuDNN)
cargo build --release --features coreml # macOS Apple Silicon# Sync your data from Granola (requires being logged into Granola app)
grans sync
# Now you can query your meetings
grans list
grans search "project kickoff"grans [OPTIONS] <COMMAND>
| Flag | Description |
|---|---|
--db <path> |
Use a specific database file instead of the default |
--token <token> |
Use a specific API token instead of reading from Granola's config |
--json |
Output as JSON |
--no-color |
Disable colored output (human-readable format without ANSI codes) |
--utc |
Display timestamps in UTC instead of local time |
--verbose / -v |
Enable verbose debug output (written to stderr) |
grans stores your meeting data in a local SQLite database at:
- macOS:
~/Library/Application Support/grans/grans.db - Linux:
~/.local/share/grans/grans.db - Windows:
%APPDATA%/grans/grans.db
Data is fetched from the Granola API using grans sync and accumulates over time. Unlike Granola's local cache (which only holds recent meetings), grans preserves all your synced data indefinitely.
grans uses a task-centric CLI design. Common tasks are promoted to top-level commands, while entity exploration and administrative tasks are grouped under browse and admin respectively.
Daily Use Commands (top-level):
sync- Sync data from Granola APIlist(ls) - List meetingsshow- Show meeting detailssearch(s) - Ranked search across meetings, transcripts, notes, and panelsgrep(g) - List every meeting containing given wordswith(w) - Show meetings with a personrecent- Show this week's meetingstoday- Show today's meetingsembed- Build embeddings for semantic searchdropbox- Dropbox sync (init, push, pull, status, logout)info- Show database statistics
Browse Commands (entity exploration):
browse people- List/show people and their meetingsbrowse calendars- List calendars and eventsbrowse templates- List/show panel templatesbrowse recipes- List/show recipes
Admin Commands (maintenance):
admin db- Database management (clear, info, list)admin token- Print the current Granola API tokenbenchmark quality- Measure search quality (FTS or semantic) against a labeled test suite
Sync your data from the Granola API to your local database.
# Full sync (all data types)
grans sync
# Sync specific data types
grans sync documents # Just documents
grans sync transcripts # Just transcripts (one API call per document)
grans sync panels # Just AI-generated panels (one API call per document)
grans sync people # Just people
grans sync calendars # Just calendar events
grans sync templates # Just templates
grans sync recipes # Just recipes
# Fetch one document's transcript (full ID or unique prefix), replacing any existing transcript
grans sync transcripts 504fe9f6
grans sync transcripts 504fe9f6 --dry-run
grans sync transcripts 504fe9f6 --embed # Rebuild embeddings afterward
# Options
grans sync --dry-run # Preview what would sync
grans sync transcripts --embed # Build embeddings after syncing transcripts
grans sync documents --limit 50 # Limit to 50 documents
grans sync documents --since 7d # Only docs updated in last 7 days
grans sync transcripts --delay-ms 500 # Rate limiting for transcripts
grans sync transcripts --retry # Retry previously failed documents
grans sync panels --limit 10 # Fetch panels for up to 10 documents
grans sync panels --retry # Retry previously failed panel fetchesNote: Sync requires a Granola auth token. By default, it reads the token from Granola's local config, transparently decrypting the supabase.json.enc store that recent Granola versions use (falling back to the legacy plaintext supabase.json). You can also provide a token explicitly with --token:
grans --token <TOKEN> syncOn macOS, the decryption key lives in the login Keychain, so the first sync shows a Keychain access prompt ("grans wants to use your confidential information stored in 'Granola Safe Storage'"). Choose "Always Allow" to skip it on later runs, or pass --token to avoid reading the Keychain at all.
To get the token from a machine with Granola installed:
grans admin token # Print to stdout
grans admin token --clipboard # Copy to clipboard without printingTwo verbs query meeting content, with two different promises:
grans search(aliass) is ranked discovery: the best few meetings for a query, matched by meaning as well as by words. Keyword (FTS5) and semantic rankings are fused with reciprocal rank fusion, then the top candidates are reranked by a cross-encoder. Results come from bounded candidate pools, so search shows aTop N match(es)list and never reports a corpus total.grans grep(aliasg) is complete lexical lookup: every meeting where the words literally appear. ItsFound N meeting(s)count is a fact about your synced data, and--limitonly trims how many are shown. Grep never loads models and never prompts.
When search finds any meetings containing the query's literal words, it says so in a footer and points at grep, e.g. 312 meeting(s) contain these words; grans grep "budget" lists them all. The suggested command echoes any search filters that affect the count (--in, --meeting, date flags, --include-deleted), so running it reports the number the footer claims.
Migrating from the old flags: search --keyword is now grep, search --speaker me is now grep --speaker me, and --hybrid is gone because hybrid retrieval is simply what search does.
# Ranked discovery: fuses keyword + semantic rankings, then reranks
grans search "standup"
grans s "standup" # short alias
grans search "quarterly budget review" --min-score 0.5 # drop low-relevance results
# Skip the rerank stage for a faster fusion-only search (no relevance scores)
grans search "quarterly budget review" --fast
# Complete lookup: every meeting containing these words
grans grep "budget"
grans g "budget" # short alias
# Complete and speaker-attributed: only that speaker's utterances count
grans grep "action items" --speaker me # things you said
grans grep "deadline" --speaker other # things others said
# Search specific targets (both verbs)
grans search "AI" --in titles
grans grep "budget" --in titles,notes
grans search "action items" --in panels
grans grep "demo" --in transcripts --date this-week
# Limit results (default 10, use 0 for no limit)
grans search "budget" --limit 5
grans grep "budget" --limit 0 # list every match
# Show more match snippets per meeting (default 1)
grans search "budget" --matches 3
# Show context around each match (utterances for transcripts, sections for
# AI notes, paragraphs for notes); both verbs
grans search "action items" --context 3
grans grep "action items" --context 2
# Limit to a specific meeting (ID or title substring); both verbs
grans search "budget" --meeting "Weekly Standup"
grans grep "budget" --meeting "Weekly Standup"
# Include soft-deleted meetings in results (both verbs)
grans search "budget" --include-deletedRanked search runs keyword and semantic retrieval together and fuses the two rankings with reciprocal rank fusion, so a meeting ranked well by either retriever surfaces, and one ranked well by both rises to the top. The top 50 fused candidates are then scored by a cross-encoder reranker (jina-reranker-v1-turbo-en) for how well each meeting actually answers the query, and the final order blends that judgment with the fusion ranking and a small boost for meetings whose title matches the query (damped when many meetings share the title, as recurring series do). Reranking takes roughly 2.2 seconds per query on CPU, most of it model inference; --fast skips the stage and returns fusion-order results (no relevance scores) in about 75 milliseconds.
Grep matches every word in the query, in any order (grans grep "budget review" finds meetings that mention both words; quote a phrase inside the query, e.g. grans grep '"budget review"', to require it verbatim). Results are ranked by relevance: meetings whose title contains the query come first, then content matches ranked by BM25, with newer meetings breaking ties. Use grep when completeness is the point, e.g. auditing every mention of a term, or when you need matches attributed to a speaker: --speaker me|other keeps only meetings where that speaker's transcript utterances match the query, and the cards show exactly those utterances. Notes and AI notes carry no speaker, so combining --speaker with an --in list that excludes transcripts is an error. Speaker filtering is grep-only because semantic retrieval has no per-utterance attribution, so search could not honor the filter without capping the answer.
Both verbs render the same cards. Each card shows why the meeting matched: the source of the best match (AI notes with its section heading, your notes, or transcript with time and speaker), a snippet with the query terms highlighted, and a +N more matches line when the meeting matched in more places. --matches N shows up to N snippets per meeting (default 1), and --context N renders N neighboring units around each shown match inside the card (the utterances around a transcript hit, the sections around an AI-notes hit, the paragraphs around a notes hit), with the matched unit shown whole. In search results, a meeting that matched semantically but contains none of the query's literal words shows its best-matching passage without highlights, and a meeting that matched only by its title says title match. The relevance score is not shown in the card view; --json carries it (score), along with which retrievers surfaced each meeting (signals), the full match list, and snippet highlight offsets. --min-score drops search results below a relevance threshold; it conflicts with --fast, since only the rerank stage produces that score. Both verbs support --in, --meeting, date filters, and --limit (which counts meetings everywhere).
The JSON envelopes differ where the contracts do: grep JSON reports total_meetings (the complete count), while search JSON reports keyword_total (the uncapped count of meetings containing the query's words, backing the footer) and no total, because its meeting list is a pooled best-k.
The semantic half of search uses a local embedding model (nomic-embed-text-v1.5) to match by meaning rather than exact keywords. Embeddings are built from transcripts, AI-generated panel sections, and your notes, and are stored in the main database.
A grans search touches local models and embeddings; grans grep never does. The first search downloads the embedding model (~270MB) and, when reranking, the reranker model too (~150MB); both are one-time downloads. A search may also prompt for confirmation before embedding new content if more than 200 chunks are unembedded. Use --yes (-y) to skip the prompt:
grans search "deployment" --yesWhen an upgrade changes the embedding model, existing embeddings are detected as stale and rebuilt automatically on the next embed or search. This full rebuild is a one-time cost and can take a while on large databases; run grans embed -y to do it at a time of your choosing.
Transcript chunks include speaker labels ([You] / [Other]) when speaker data is available, improving search relevance for queries like "what did I say about..." vs "what did they say about...".
Build embeddings for hybrid search. Use this to control when embedding happens instead of waiting for the first search.
# Build embeddings for new/changed chunks (prompts for confirmation)
grans embed
# Skip confirmation prompt
grans embed --yes
grans embed -y
# Show embedding status with per-type breakdown (transcripts/panels/notes)
grans embed status
# Clear all embeddings (for dev/testing)
grans embed clear
# Clear N most recent embeddings
grans embed clear --count 10
# Force re-embed everything: clear then embed
grans embed clear --yes && grans embed --yesEmbeddings are built automatically during grans sync --embed or on the first search if not already present. The embed command gives you explicit control over when this happens, which is useful when you have a lot of new content and don't want the first search to block.
# List all meetings
grans list
grans ls # short alias
# Filter by date
grans list --date today
grans list --date this-week
grans list --date last-month
grans list --from 2026-01-01 --to 2026-01-15
# Filter by person
grans list --person "lisa"
# Include soft-deleted meetings
grans list --include-deleted
grans list --date this-week --include-deleted
# This week's meetings (shortcut)
grans recent
# Today's meetings (shortcut)
grans today# Show meeting by title or ID
grans show "Claude Code"
grans show 3219f4e3 # by ID prefix
# Export transcript or notes
grans show "Weekly Standup" --transcript > transcript.txt
grans show "Weekly Standup" --notes > notes.md
# Both together (notes first, then transcript)
grans show "Weekly Standup" --notes --transcript
# Filter transcript by speaker
grans show "Weekly Standup" --transcript --speaker me # only your utterances
grans show "Weekly Standup" --transcript --speaker other # only others' utterances
# AI-generated panels are shown automatically under "AI Notes"
# when present for a meeting
# JSON format (includes source field for speaker identification)
grans show "Weekly Standup" --transcript --json
grans show "Weekly Standup" --notes --json# Show all meetings with a person
grans with "todd"
grans w "todd" # short alias
# Filter by date
grans with "alice" --date this-week
grans with "bob" --from 2026-01-01
# Include soft-deleted meetings
grans with "todd" --include-deleted# List all people
grans browse people list
# Filter by company
grans browse people list --company "Acme"
# Show person details
grans browse people show "lisa"# List calendars
grans browse calendars list
# Show events
grans browse calendars events
grans browse calendars events --calendar "user@example.com" --date this-week# List panel templates
grans browse templates list
grans browse templates list --category "Team"
# Show template details
grans browse templates show "Stand-Up"# List recipes
grans browse recipes list
grans browse recipes list --visibility public
# Show recipe details
grans browse recipes show "meeting-summary"Show statistics about your local database.
# Show database statistics
grans info
# JSON output for scripting
grans info --jsonDisplays content counts (documents, transcripts, panels, people, embeddings, etc.), date range of documents, embedding model, and database information (path, size, schema version).
Manage the local SQLite database.
# Clear database (will require re-sync)
grans admin db clear
# Clear all database files
grans admin db clear --all
# Show database location and size
grans admin db info
# List all database files
grans admin db listShare your grans database across multiple machines via Dropbox.
Why use this? Two operations in grans are slow:
-
Transcript sync (
grans sync transcripts) - Fetches transcripts from Granola's API with rate limiting (~1.5s per document). For 200 meetings, that's ~5 minutes. -
Embedding generation - The first search builds vector embeddings for transcripts, panel sections, and notes, which takes time on CPU.
Once you've done this work on one machine, Dropbox sync lets you share the results instead of repeating it everywhere.
Initial setup (on your primary machine):
# 1. Sync all data including transcripts from Granola API
grans sync
# 2. Build embeddings (slow first time)
grans embed -y
# 3. Connect to Dropbox (one-time OAuth)
grans dropbox init
# 4. Upload your database
grans dropbox pushOn other machines:
# 1. Connect to Dropbox
grans dropbox init
# 2. Download the databases
grans dropbox pull
# 3. Queries now work instantly - no need to re-sync or rebuild
grans search "deployment"Keeping machines in sync:
# After syncing new data on your primary machine
grans sync
grans dropbox push
# On other machines
grans dropbox pullCommands:
| Command | Description |
|---|---|
grans dropbox init |
One-time Dropbox authentication |
grans dropbox push |
Upload database to Dropbox |
grans dropbox pull |
Download database from Dropbox |
grans dropbox status |
Show sync status with local vs remote comparison |
grans dropbox logout |
Remove Dropbox authentication |
Sync status shows a side-by-side comparison of local and remote database:
Sync Status
───────────
Authentication: Connected
Last push: 2025-01-27 15:30:00 UTC
Last pull: Never
Local Remote
───── ──────
Documents: 423 418
With transcripts: 389 385
Utterances: 52.8K 51.2K
Date range: 2023-06 → 2025-01 2023-06 → 2025-01
Schema version: 3 3
Database size: 45.0 MB 44.8 MB
Embeddings: 52.8K 51.2K
This helps you see at a glance whether your local database is ahead of or behind the remote copy, without downloading the full database.
Conflict handling: Sync refuses to overwrite newer files by default. Use --force to override:
grans dropbox push --force # Overwrite remote even if it's newer
grans dropbox pull --force # Overwrite local even if it's newerWhat gets synced:
- Database (meeting data, transcripts, FTS indices, vector embeddings for semantic search)
The sync uses a sandboxed Dropbox app folder (Apps/grans/), so it only accesses its own files, not your full Dropbox.
- TTY (default): Human-readable formatted output with colors in terminals, automatically stripped when piped. Timestamps are shown in your local timezone.
- JSON (
--json): Structured JSON output for scripting. Timestamps remain as raw ISO 8601 UTC strings.
# Pipe output (colors automatically stripped)
grans list | head -5
# JSON for scripting
grans list --json | jq '.[].title'
# Force no color in terminal
grans list --no-color
# Display timestamps in UTC instead of local time
grans list --utcUse --verbose (or -v) to enable debug logging on stderr. This shows API requests/responses, timing, auth resolution, and sync details without affecting stdout output.
# Debug a sync operation
grans -v sync
# Debug with JSON output (debug on stderr, JSON on stdout)
grans --json -v info
# Fine-grained control via GRANS_LOG env var
GRANS_LOG=grans::api=debug grans sync
GRANS_LOG=grans::api=trace,grans::db=debug grans syncThe GRANS_LOG environment variable uses env_logger filter syntax and takes precedence over --verbose when set.
Update grans to the latest version from GitHub.
# Check for updates without installing
grans update --check
# Download and install the latest version
grans update
# Show current version
grans --versionThe update command downloads the appropriate binary for your platform from GitHub releases, verifies its SHA256 checksum, and replaces the current binary.
Build Waiting: If a release build is in progress on GitHub Actions, grans will detect it and offer to wait:
# Interactive: prompts to wait if a build is in progress
grans update
# Auto-wait for builds (for scripts/CI)
grans update --wait
# Set a custom timeout (default: 600 seconds)
grans update --wait --timeout 300Private Repositories: For private repositories, grans will prompt to use your gh CLI credentials if available. For non-interactive/scripted usage:
# Use gh CLI auth automatically (no prompt)
grans update --check --use-gh-auth
# Or set an environment variable
export GH_TOKEN=$(gh auth token)
grans update --checkMeasure search quality against a suite of queries with known expected results. The suite is a JSON file you author against your own database:
{
"description": "My search quality suite",
"queries": [
{
"query": "what did we decide about the API redesign",
"query_type": "paraphrase",
"relevant_meetings": ["Architecture Sync", "API v2 Planning"],
"relevant_meeting_ids": ["abc123", "def456"],
"rationale": "Both meetings covered the decision"
}
]
}Results are matched to labels by document ID when relevant_meeting_ids is present, otherwise by exact title against relevant_meetings (ID matching is preferred; recurring meetings often share one title, which over-credits title matching). query_type is an optional stratum label (for example exact-term, paraphrase, mixed); when present, the report includes a per-stratum breakdown. rationale is a free-text note for your own reference.
# Score semantic search (the default mode) at k=10
grans benchmark quality --file my-benchmark.json
# Score keyword (FTS) search instead
grans benchmark quality --file my-benchmark.json --mode fts
# Compare modes: per-query rank table plus win/loss/tie summary
grans benchmark quality --file my-benchmark.json --compare fts,semantic
# Check top 5 results
grans benchmark quality --file my-benchmark.json --k 5
# Show detailed results for each query
grans benchmark quality --file my-benchmark.json --detail
# Append the run to the results ledger (ledger.jsonl in the benchmarks
# directory, with full per-query output under runs/)
grans benchmark quality --file my-benchmark.json --record --note "baseline"The benchmark reports:
- hit-rate@k: Percentage of queries where an expected meeting appears in the top k results
- recall@k: Fraction of each query's expected meetings found in the top k, averaged over queries
- MRR@k: Average of 1/rank of the first relevant result (0 when it falls outside the top k)
- Latency: Average and median per-query search time for the mode
This is useful for:
- Comparing keyword and semantic retrieval on the same suite
- Testing chunking strategy changes
- Evaluating embedding model updates
- Comparing search performance across database versions
Use the --db flag to benchmark against a different database file without affecting your main database:
grans --db /path/to/test.db benchmark quality --file my-benchmark.jsonRelative terms: today, yesterday, this-week, last-week, this-month, last-month
Duration shorthands: 3d (3 days ago), 2w (2 weeks ago), 1m (1 month ago):
grans list --from 2w # meetings from the last 2 weeks
grans list --from 4w --to 2w # meetings between 4 and 2 weeks ago
grans sync transcripts --since 7dAbsolute ranges with --from and --to (ISO 8601 dates):
grans list --from 2026-01-01 --to 2026-01-31
grans list --from 2026-01-15 # open-ended