Getting Started
Install Tennis, index a directory, and run the search that shows why hybrid ranking matters.
Install one binary, point it at a folder, ask it a question. No API key, no server, no configuration file.
Install
curl -fsSL https://pub-dff7cf6c315440a58ea79dee7915d2c4.r2.dev/install.sh | shThat picks the archive for your OS and architecture, verifies it against the SHA-256 the release was published with, and installs it to ~/.local/bin. Pin a version with TENNIS_VERSION=0.2.0, or change the directory with TENNIS_INSTALL_DIR.
Or with Go:
go install github.com/satoricorp/tennis/cmd/tennis@latestOr from source:
git clone https://github.com/satoricorp/tennis && cd tennis && CGO_ENABLED=0 go build -o tennis ./cmd/tennisCGO_ENABLED=0 is not a suggestion — it is the whole point. Tennis uses a pure-Go SQLite driver and a static embedding model, so the result is one self-contained binary with no shared libraries to find at runtime.
One download, once
On first use Tennis fetches the embedding model (~123MB) to ~/.cache/tennis. After that it never touches the network. See Embedders.
Index something
$ tennis add ~/Documents/notes
tennis: created namespace "context" bound to builtin:potion-retrieval-32M
tennis: no ANTHROPIC_API_KEY or OPENAI_API_KEY; cards will carry the opening message instead of a summary
tennis: /Users/you/Documents/notes: reading plain files
imported 3, skipped 0 unchanged, 3 chunks in "context", 3 cards in /Users/you/tennisYou never named a namespace, so add used context, the default, and created it on first use. By default it reads every file with text in it — notes, spreadsheets, Word files, PDFs; pass --ext to narrow it to some extensions.
The note about API keys is about cards, not search: every file is also written to ~/tennis as a short markdown card, and a key turns the card's opening lines into a summary. Search never needs one.
Search it
$ tennis search "keep me signed in"
> # Session handling
Make the login flow remember the user between sessions. The session cookie is
set with an expiry so the browser keeps it after the tab closes.
auth.md [2026-10-05] 0.0328The query and the document share no words. auth.md came back because the vectors agree about what the sentence means.
You get one result, printed whole: the words first, then where they came from, when, and how strongly it matched. You asked a question, so you get an answer you can actually read — not ten truncated lines to squint at.
Ask for more
-k is how you ask to compare. Then every result is numbered, the best one included, because at that point you are reading a ranking rather than an answer:
$ tennis search "keep me signed in" -k 3
1. auth.md [2026-10-05] 0.0328 [kw#1 sem#1]
# Session handling
Make the login flow remember the user between sessions. The session cookie
is set with an expiry so the browser keeps it after the tab closes.
2. retry.md [2026-10-05] 0.0161 [sem#2]
# Retries
Failed requests back off exponentially, doubling the delay each attempt up
to a cap of thirty seconds.
3. config.md [2026-10-05] 0.0159 [sem#3]
# Configuration
Write a parser for TOML configuration files. Values from the file are merged
over the defaults.Read the tags
The [kw#1 sem#1] tag says which ranker found a hit, and where in that ranker's list. It is the difference between a result both rankers agreed on and one only the vectors liked.
auth.md scores 0.0328 because BM25 and the vectors both ranked it first. retry.md scores half that — the vectors put it second, and keyword search never found it at all. A hit tagged with only one ranker and a low score is usually noise. Tennis has no relevance threshold and will not invent one; it shows you the evidence instead. See How it works.
Re-indexing is nearly free
$ tennis add ~/Documents/notes 2>/dev/null
imported 0, skipped 3 unchanged, 0 chunks in "context"Unchanged files are never re-read into the model, and their cards are left as they are. Re-adding a large corpus after editing one file costs about as much as indexing one file.
Index your history
Nothing to index yet? Start with the history you already have. Point add at a ChatGPT or Claude export zip, or at ~/.claude or ~/.codex, and search it in one command:
$ tennis add ~/.claude
tennis: no ANTHROPIC_API_KEY or OPENAI_API_KEY; cards will carry the opening message instead of a summary
tennis: /Users/you/.claude: reading Claude Code session transcripts
imported 5, skipped 0 unchanged, 5 chunks in "context", 2 cards in /Users/you/tennis
$ tennis ls
DATE SOURCE DOCS TITLE
2026-10-05 09:02 file 1 auth.md
2026-10-05 09:02 file 1 config.md
2026-10-05 09:02 file 1 retry.md
2026-08-03 04:30 claude-code 2 TOML config parser with defaults
2026-08-01 05:01 claude-code 3 Flaky auth test: token refresh window
5 sessions and filesls shows what is there without running a search: your three notes and the two conversations, newest first, one row each. The two new cards are a readable copy of each conversation, written to ~/tennis beside the ones for your notes — and summarized, if you set an API key. See Importing sessions and Cards.