CLI
add, search, ls, rm, ns, serve, and version — every flag, with real output.
Two commands cover almost everything: add puts things in, search gets them out. Neither needs a namespace, because both default to context. ls shows what is there.
tennis add ~/Documents/notes
tennis search "keep me signed in"
tennis lstennis add
add is the one way in. Point it at an export archive, a directory of agent transcripts, or a pile of files, and it works out what it was handed:
tennis add ~/Downloads/chatgpt-export.zip # a ChatGPT data export
tennis add ~/Downloads/claude-export.zip # a Claude data export
tennis add ~/.claude # local Claude Code sessions
tennis add ~/.codex # local Codex sessions
tennis add ./docs # a directory tree of files
tennis add ./a.md ./b.md # specific filesDetection reads the source, not the filename. When the guess is wrong, or you would rather be explicit, name the source:
tennis add --chatgpt ~/Downloads/export.zip
tennis add --claude ~/Downloads/export.zip
tennis add --claude-code ~/.claude
tennis add --codex ~/.codex
tennis add --files ~/Documents/notesA .zip, an already-unzipped directory, and a single transcript are all acceptable. Importing sessions covers what Tennis reads out of each format and what it deliberately skips.
A folder add reads every file that holds text, each by whatever reads its kind:
- Plain text of any kind, whatever the extension: notes, code, JSON, CSV, a
Makefile. - Spreadsheets, Word, PowerPoint, HTML and EPUB (
.xlsx,.docx,.pptx,.html,.epub) are read by Tennis itself: a workbook sheet by sheet with its dates as dates, a deck slide by slide, a book chapter by chapter in reading order. - PDFs are read with
pdftotextwhen it is installed, and otherwise, on a Mac, through Spotlight. - RTF,
.doc,.xls,.ppt, Pages, Numbers, Keynote and email are read through macOS. A Pages, Numbers or Keynote document saved as a package — a folder macOS shows as one file — and an.rtfdare each read as the one document they are, not walked into.
The rest is passed over:
- Photos, audio, video, archives, dotfiles, and anything under a dot directory such as
.gitare skipped silently. A folder of photos is not a folder of mistakes. So are empty files and files holding nothing but whitespace. - Dependency and build folders —
node_modules,vendor,target,dist,build,__pycache__andsite-packages, wherever they appear — and whatever a.gitignoreinside the folder names are skipped silently too, but counted: the summary line ends(N folders passed over). The folder you name is read whatever it is called, sotennis add ./vendorreads it. On a Mac,.gitignorepatterns match without regard to case, as git does there by default. Application bundles (.app,.framework) are passed over and counted the same way, and so is a symbolic link to a folder, which is not followed; a link to a file is read as that file. - Other binary files, and text over 10MB, are skipped with a note on stderr. Binary detection is a NUL-byte check in the leading 8KB, the same heuristic git uses.
- Formats with no reader on this machine — a PDF on Linux without
pdftotext, or a Pages file anywhere but a Mac — are skipped with a "no reader on this machine" note that says what is missing.
tennis add ./src --files --ext .go,.ts,.rs # only these extensions (default: everything with text)
tennis add ./docs --chunk 2000 # bigger chunks for a new namespace
tennis add ~/.codex --ns work # a namespace other than the default
tennis add ~/.claude --cards ~/notes/sessions # write the cards somewhere else
tennis add ~/.claude --no-cards # or not at all
tennis add ./docs --json # machine-readable| Flag | Meaning |
|---|---|
--ns <name> | namespace (default context, or $TENNIS_NS) |
--format <fmt> | auto (default), chatgpt, claude, claude-code, codex, files |
--chatgpt / --claude / --claude-code / --codex / --files | shorthand for --format |
--ndjson | read documents from stdin instead of a path; see below |
--per <unit> | one document per turn (default) or conversation |
--ext <list> | only these extensions, when the source is plain files (default: everything with text in it) |
--chunk <n> | chunk size in characters, for a new namespace |
--model <name> | built-in model for a new namespace (default potion-retrieval-32M) |
--openai <model> | use an OpenAI model instead (requires OPENAI_API_KEY) |
--cards <dir> | where cards are written (default ~/tennis, or $TENNIS_CARDS) |
--no-cards | skip writing cards |
--db <path> | database file (default ~/.tennis/db.sqlite, or $TENNIS_DB) |
--json | machine-readable output on stdout; progress goes to stderr |
Every conversation and every file add reads is also written out as a card: one markdown file each in ~/tennis, with its details in frontmatter and a summary when you have an API key. Without a key, a conversation's card carries its opening message and a file's its first lines. Adding the same source again rewrites only the cards whose conversation or file changed, so a re-add makes no model calls for what is already summarized. --ndjson documents get no card, because they are neither conversations nor files. Cards has the whole story.
$ 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/tennisRe-running add is an incremental update. Files are keyed by path and skipped when contents and metadata are byte-identical to what is stored; session documents are keyed by the export's own conversation and message IDs, so a fresh export months later writes only what is new.
Given several paths, a path that fails partway ends the run but not retroactively: what the earlier paths read is written and their cards are finished, the usual summary is printed, and the error is what the command exits on. With --json the report lists the sources that went in and carries the error beside them. A path that does not exist at all is refused before anything is read, so a typo never leaves an empty namespace behind.
tennis search
tennis search "exponential backoff"
tennis search "keep me signed in" -k 5 # top 5, not just the best
tennis search "retry" --mode keyword # BM25 only
tennis search "retry" --mode semantic # vectors only
tennis search "auth" --where 'status=merged' # filter by attribute
tennis search "auth" --where 'cost>5,status!=failed'
tennis search "auth" --ns work
tennis search "auth" --json
tennis search --where 'session=<id>' # read a whole conversation, no query| Flag | Meaning |
|---|---|
-k <n> | how many results (default 1; -n is an alias) |
--mode <mode> | hybrid (default), keyword, semantic |
--where <filter> | attribute filter: key=value, key>value, key!=value, comma-separated; with no query, prints every match in full |
--ns <name> | namespace (default context, or $TENNIS_NS) |
--db <path> | database file |
--json | full results with scores and attributes |
One result by default, printed whole: the words, then where they came from, when, and how strongly it matched. You asked a question, so you get an answer you can read, wrapped to your terminal.
$ 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-k is how you ask for more. Then every result is numbered, the best one included, because at that point you are reading a ranking rather than an answer, and each carries a tag saying which ranker found it and where:
$ 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.[kw#1 sem#1] means BM25 and the vectors both ranked it first. [sem#2] means only the vectors found it; a hit one ranker surfaced is a different kind of answer than one both agreed on. Text is wrapped to the terminal's width, capped at 84 columns; when stdout is a pipe it is 80 columns and no color.
When nothing matches, search says so rather than padding the list:
$ tennis search "signed in" --mode keyword
no matchesThat is --mode keyword doing its job: no document contains the word signed, and in is a stop word. The default hybrid mode would still return the semantic hits.
--json includes each hit's stored attributes, not just its text and score:
[
{
"id": "/Users/you/Documents/notes/auth.md",
"score": 0.01639344262295082,
"text": "# Session handling\n\nMake the login flow remember the user between sessions…",
"attributes": {
"kind": "file",
"modified": "2026-08-17T19:15:37Z",
"name": "auth.md",
"path": "/Users/you/Documents/notes/auth.md",
"size": 227
},
"keyword_rank": 0,
"semantic_rank": 1
}
]keyword_rank: 0 means BM25 did not return this document at all — the ranks are 1-based, and 0 is "absent", not "first".
Reading, not ranking
Given a --where filter and no query, search stops ranking and prints every match in full. This is the command at the foot of every card: --where 'session=<id>' prints the whole conversation, --where 'path=<file>' the file's text exactly as it was indexed.
$ tennis search --where 'session=7f3a2c10-1b2e-4d5f-9a8b-0c1d2e3f4a5b'
# Flaky auth test: token refresh window
## summary
Flaky auth test: token refresh window
## user
The auth test fails about one run in five. Can you find out why?
## assistant
The token refresh window is shorter than the test's sleep, so the second request runs with an expired token.
I widened the window and added a regression test.A conversation is printed in turn order under its title, with back-to-back turns from one speaker merged — the same rendering the summarizer reads. A Claude Code session that ran subagents prints its own thread first, then each subagent's turns under a heading of their own:
$ tennis search --where session=7f3a2c10
# Flaky auth test
## user
The auth test fails about one run in five. Can you find out why?
## assistant
The token refresh window is shorter than the test's sleep. I widened it and added a regression test.
## subagent agent-search
## user/subagent
Find every caller of refreshToken.
## assistant/subagent
Three callers: auth/session.go, auth/middleware.go, and the test helper.A filter that matches several sessions or files prints each one whole, oldest first, never interleaved, and heads each so you can tell where one ends: a file with ==> <path> <==, the way head heads one of several files, and a session with its title, or its source and id when it has none.
$ tennis search --where kind=file
==> /Users/you/Documents/notes/auth.md <==
# Session handling
The session cookie is set with an expiry so the browser keeps it after the tab closes.
==> /Users/you/Documents/notes/config.md <==
# Configuration
Values from the TOML file are merged over the defaults.One match prints with no such heading, which is what a card's command asks for. --json returns the matching documents as [{"id", "text", "attributes"}] in the same order. -k caps how many documents print, but only when you pass it — the default of 1 is for ranked results — and -k 0 prints them all. --mode keyword or --mode semantic with no query is an error, since there is nothing to rank. A filter that matches nothing prints no matches, and search with neither a query nor a filter is still a usage error.
tennis ls
ls answers "what does tennis actually have" without running a search. It reads metadata only, so it stays fast at a size where search has started to cost real time.
$ 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 filesIt groups by session rather than listing documents, because add stores one document per turn by default and a flat listing of those is thirty thousand rows nobody asked for. A conversation is one row however many turns it was stored as, and DOCS is how many. A file has no session, so it is a row of its own, dated by when it was last modified and listed among the conversations by date. Newest first; dates are shown in your local time. A bare word filters by source, because tennis ls chatgpt is what people type before they read the flags:
tennis ls chatgpt # only ChatGPT sessions
tennis ls claude-code # only Claude Code sessions
tennis ls --where branch=main # any attribute filter, same syntax as search
tennis ls --docs # documents, not sessions
tennis ls -n 50 # more than the default 25
tennis ls -n 0 # everything
tennis ls --offset 25 # the next page
tennis ls --asc # oldest first
tennis ls --docs --sort name # by another attribute
tennis ls --json| Flag | Meaning |
|---|---|
--docs | list documents instead of grouping by session |
-n <count> | how many to list (default 25; 0 for all) |
--offset <count> | skip this many |
--where <filter> | attribute filter, same syntax as search |
--sort <attr> | attribute to sort by (default created, or modified for a file) |
--asc | oldest first |
--ns <name> | namespace (default context, or $TENNIS_NS) |
--db <path> | database file |
--json | machine-readable output |
--docs is the flat view: one row per turn and per file, with a file's path as its id:
$ tennis ls --docs -n 4
DATE SOURCE KIND CHUNKS ID
2026-10-05 09:02 file 1 /Users/you/Documents/notes/auth.md
2026-10-05 09:02 file 1 /Users/you/Documents/notes/config.md
2026-10-05 09:02 file 1 /Users/you/Documents/notes/retry.md
2026-08-03 04:30 claude-code message 1 claude-code:b81e9d44-6c7a-4e2b-8f1d-3a4b5c6d7e8…
4 of 8 documents (--offset 4 for more, -n 0 for all)The tally under the table goes to stderr, so tennis ls --json | jq sees only the rows. In the session view --json gives each row's key, document and chunk counts, and the attributes shared across the group; a file's row is marked "ungrouped": true and carries the file's own attributes. Two of the rows above, as JSON:
[
{
"key": "/Users/you/Documents/notes/retry.md",
"ungrouped": true,
"documents": 1,
"chunks": 1,
"attributes": {
"kind": "file",
"modified": "2026-10-05T14:02:11Z",
"name": "retry.md",
"path": "/Users/you/Documents/notes/retry.md",
"size": 114
}
},
{
"key": "b81e9d44-6c7a-4e2b-8f1d-3a4b5c6d7e8f",
"documents": 2,
"chunks": 2,
"attributes": {
"created": "2026-08-03T09:30:08Z",
"source": "claude-code",
"title": "TOML config parser with defaults"
}
}
]tennis add --ndjson
Every other source names a path; --ndjson is for content that never touched disk — event lines, chat turns, agent claims, anything a program produces rather than a file. It reads newline-delimited JSON from stdin, one document per line:
echo '{"id": "e1", "text": "deploy failed with a connection timeout", "attributes": {"kind": "event"}}' \
| tennis add --ndjson --ns agents
tennis add --ndjson --ns agents < events.ndjson
tennis add --ndjson --ns agents --openai text-embedding-3-small < events.ndjson
tennis add --ndjson --ns agents --json < events.ndjsonEach line is {"id": "...", "text": "...", "attributes": {...}}. attributes is optional; everything else follows the rest of add, including creating the namespace on first use and skipping documents that are byte-identical to what is stored. Cards are not written for these documents — they are neither conversations nor files.
A line that isn't valid JSON, or is missing id or text, is reported to stderr with its line number and does not stop the batch. The command exits nonzero if any line failed:
$ tennis add --ndjson --ns agents --json < events.ndjson
tennis: ndjson: line 3: unexpected end of JSON input
{
"chunks": 2,
"failed": 1,
"skipped": 0,
"written": 2
}
error: 1 line(s) failed to parsetennis ns
tennis ns list
tennis ns create agents # built-in model
tennis ns create agents --openai text-embedding-3-small
tennis ns rm agents$ tennis ns list
NAMESPACE EMBEDDER DIMS DOCS CHUNKS
agents builtin:potion-retrieval-32M 512 2 2
context builtin:potion-retrieval-32M 512 3 3The embedder column is not decoration — it is the binding a namespace is stuck with for life. See Embedders.
ns rm removes the namespace and every document written to it. It names the cost before paying it:
$ tennis ns rm agents
tennis: removing "agents" takes its 2 documents (2 chunks) with it
removed "agents"tennis rm --ns agents does the same thing and costs the same warning; see tennis rm.
tennis rm, serve, version
tennis rm /abs/path/to/auth.md # delete from the default namespace
tennis rm e1 e2 --ns agents # ...or from a named one
tennis rm --ns agents # no ids: remove the namespace itself
tennis serve # local HTTP API on 127.0.0.1:8817
tennis serve --addr 127.0.0.1:9000
tennis version # tag and the commit it was built fromrm takes document ids and reads --ns like add and search do. With no ids it removes the whole namespace instead, which is the same request as tennis ns rm — see tennis ns for what that costs. The flag has to be typed: an unflagged tennis rm resolves the namespace from $TENNIS_NS, and if that counted, a bare Enter against an exported TENNIS_NS would drop a namespace nobody named.
$ tennis rm --ns agents
error: usage: tennis rm <id...> [--ns name]
to remove a namespace instead: tennis rm --ns <name>rm used to take the namespace first, and that spelling would still run — against the wrong namespace, deleting a document named after the right one — so it is refused rather than obeyed:
$ tennis rm agents e1
error: rm takes ids, not a namespace: try `tennis rm e1 --ns agents`version prints the release tag and the commit the binary was actually built from. A build from a working tree has no tag, so it says dev and leans on the commit, with dirty appended when the tree had uncommitted changes; a go install ...@version binary carries no commit and reports the module version instead.
$ tennis version
tennis v0.1.0 (ee10c3ed7123)serve is documented in full under HTTP API.
Common flags
| Flag | Meaning |
|---|---|
--db <path> | database file (default ~/.tennis/db.sqlite, or $TENNIS_DB) |
--ns <name> | namespace for add, ls, search and rm (default context, or $TENNIS_NS) |
--cards <dir> | where add writes cards (default ~/tennis, or $TENNIS_CARDS) |
--json | machine-readable output on stdout; progress goes to stderr |
Flags may come before, between, or after positional arguments: tennis search --ns work "retry" and tennis search "retry" --ns work are the same command.
The full surface
tennis --help lists the six commands a person reaches for and stops. The rest of the surface is still supported and tested, just not advertised; tennis --agents prints all of it, as plain text without the logo, because the reader is usually a program:
$ tennis --agents
tennis — local hybrid search. Keyword + semantic, one file, no server.
USAGE
tennis <command> [flags]
COMMANDS
add <path...> index sessions or files
search <query> search
ls [source] list what tennis has, newest first
rm <id...> delete documents, or a whole namespace with --ns
ns [list|create|rm] manage namespaces
version
ALSO AVAILABLE
serve start the local HTTP API on 127.0.0.1:8817
seed <namespace> <path...> like add --files, namespace first
import <namespace> <path...> like add, namespace first
match <namespace> <query> like search, namespace first
SOURCES
tennis add --chatgpt <export.zip> a ChatGPT export
tennis add --claude <export.zip> a Claude export
tennis add --claude-code ~/.claude Claude Code transcripts
tennis add --codex ~/.codex Codex transcripts
tennis add --files ~/Documents/notes plain files
tennis add --ndjson < docs.ndjson documents from a program, on stdin
Without a source flag, add reads the path and works out which it is.
COMMON FLAGS
--db <path> database file (default ~/.tennis/db.sqlite, or $TENNIS_DB)
--cards <dir> where summary cards are written (default ~/tennis, or $TENNIS_CARDS)
--ns <name> namespace for add, ls, search and rm (default context, or $TENNIS_NS)
--json machine-readable output
Run 'tennis <command> --help' for command flags.tennis help --agents prints the same thing. Both screens are assembled from the same pieces, so the two cannot drift apart.
The three commands under "also available" are add and search with the namespace spelled first, from before --ns existed:
tennis seed notes ./docs # like tennis add --files ./docs --ns notes
tennis import history ~/Downloads/export.zip # like tennis add ~/Downloads/export.zip --ns history
tennis match notes "keep me signed in" # like tennis search "keep me signed in" --ns notes