March 2026
How Lectr’s Recommendations Work
Lectr v4.0 ships with book recommendations. The constraint I set myself: generate useful suggestions without sending any of the user’s book titles, authors, notes, quotes, or tag names to a server.
This post describes the approach. The full technical write-up covers API protection via attestation, rate limiting, and the privacy FAQ.
The Problem
Lectr already has a Reading Portrait feature that extracts themes and sentiment from your annotations using Apple’s on-device NLP. The data is there. The question was how to use it for recommendations without leaking it.
The obvious approach is to send the Reading Portrait to a server and let an AI suggest books. But the Reading Portrait contains your actual tag names and the specific concepts extracted from your annotations. If you tag books “Grief” or “Addiction Recovery,” sending those strings to a server means the server knows something personal about you.
Semantic Proxy Mapping
The solution is a vocabulary substitution step that runs on-device before anything is sent.
Lectr ships with a codebook of 287 generic literary and thematic descriptors. Things like
“contemplative,” “resilience,” “economics,”
“sorrow.” When the app prepares a recommendation request, it takes each
user-created tag name and each NL-extracted theme and finds the nearest matches in the
codebook using Apple’s NLEmbedding.
So “Addiction Recovery” might become “recovery, resilience, transformation.” Close enough for the AI to work with. Not close enough to reconstruct the original term with certainty.
The codebook is static and ships inside the app binary. Anyone can inspect it. The mapping includes weighted random sampling, which means the same input tag won’t always produce the same proxies. This adds non-determinism that makes exact reversal unreliable.
What the Server Actually Receives
After the proxy mapping, the request payload looks like this:
{
"themes": {
"morality": 0.82,
"spirituality": 0.65,
"contemplative": 0.41,
"economics": 0.29
},
"tagClusters": [
["philosophy", "psychology", "spirituality"],
["history", "politics"]
],
"tagSentiment": {
"philosophy": 0.35,
"neuroscience": 0.12,
"historical": -0.08
},
"engagement": {
"philosophy": 8.4,
"psychology": 5.1,
"historical": 2.3
},
"noteColours": {
"yellow": 84, "blue": 31,
"pink": 12, "green": 7
}
}
No book titles. No author names. No annotation text. No user-created labels. All terms are drawn from the fixed codebook.
The server passes this to Claude (Anthropic’s API) and returns the suggestions. The payload is processed in memory on a Cloudflare Worker and discarded when the response completes. Nothing is written to disk.
Filtering Out Owned Books
The server asks the AI for more candidates than the user requested. It returns the full list. The client drops any books already in the local library, then trims to the requested count. No representation of the user’s library leaves the device.
I originally built this with a Bloom filter. That worked, but the simpler approach (overfetch and filter locally) saved ~15 KB per request and removed shared state between client and server. I wrote about that decision in Being Boring.
What the Mapping Does to the Signal
The proxy step changes the shape of the data the AI receives. Different users label the same interest differently. “Psych,” “Psychology,” “Mind & Brain,” “Cognitive Science” all converge to the same neighbourhood of proxy terms. The AI sees one coherent interest instead of four unrelated strings.
Niche tags also expand into denser profiles. A reader with three obscure tags produces a sparse signal. After mapping, those tags expand into nine proxy terms that overlap with well-known reading categories. Whether this actually produces better recommendations is a separate question, explored in Testing Lectr’s Recommendations.
What This Doesn’t Protect Against
The proxy terms still reveal the general area of your reading interests. Someone inspecting the request could infer that you read about emotionally heavy topics, even if they can’t determine your specific tags. And our server code doesn’t log request bodies, but “we don’t log it” is a policy claim, not an architectural guarantee. The architecture makes accidental retention difficult (Cloudflare Workers have no persistent filesystem), but a code change could add logging.
I wanted to be upfront about this. The full technical write-up has a section called “Honest Limits” that goes into more detail.