Tennis

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 | sh

That 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@latest

Or from source:

git clone https://github.com/satoricorp/tennis && cd tennis && CGO_ENABLED=0 go build -o tennis ./cmd/tennis

CGO_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/tennis

You 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.0328

The 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 files

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

Next steps

  • CLI — every command and flag
  • Importing sessions — index the agent and chat history you already have
  • Cards — the markdown copy of every conversation and file
  • Go SDK and HTTP API — using Tennis from code
  • /llms.txt — machine-readable docs