Hybrid search finds good candidates. Now Klippy gets the last mile: a reranker picks the best five, a tool hands them to the model as numbered passages, and the answer cites them, or says plainly that the answer isn't there. And because saved pages can carry orders, reading them locks the tools that write.
Verified agents 0.33.0@cloudflare/ai-chat 0.12.2ai 7.0.162wrangler 4.157.0
Start from e-06 on GitHub. Starting here, or behind? Get the code at that tag:
git clone https://github.com/ryanyogan/klippy cd klippy git checkout e-06
1. A reranker picks the best few
An embedding compares the question and a chunk separately. A reranker reads them together and scores how well the chunk answers the question. It's sharper and slower, so it only sees hybrid search's top 20.
First, a hit should carry its context sentence, so the reranker and the model see it. In src/retrieval/search.ts, add context: string to Hit, and c.context to the SELECT in hydrate, right before c.text:
Then create apps/agent/src/retrieval/rerank.ts:
The types gotcha. The model needs query, contexts and top_k (its docs say so), but the generated Ai_Cf_Baai_Bge_Reranker_Base_Input in worker-configuration.d.ts leaves query out. Passing an object literal straight to env.AI.run fails the excess-property check. So the input is built first, typed with & { query: string }. If a later wrangler types adds query, the intersection does no harm.
2. A search tool for Klippy
Create apps/agent/src/tools/search.ts:
20 candidates in, 5 out, each numbered so the model can cite
[n].Each passage is wrapped in
<untrusted_saved_content>and capped at 1,500 characters. You didn't write that saved web page; somebody else did.
3. Answer only from the passages
In agent.ts, add import { searchNotesTool } from './tools/search'; with the other imports. Then two lines in the system prompt:
Search always returns its top five if five exist, relevant or not. So "That's not in your notes." is the model's judgement, and the prompt is what asks for it. Practise that prompt here, on the same kind of numbered passages:
Goal Get an answer built only from the numbered passages, with a citation on each claim, that says plainly which part of the question they don't cover
What good looks likeThe coach checks the reply
- Includes the passagesnot checked yet
- Asks for citationsnot checked yet
- Ran at 1.0not checked yet
- Cites the passages by numbernot checked yet
- Says the passages don't cover shipping to Canadanot checked yet
- Each claim taken from the passages cites the passage number it comes fromnot checked yet
- Answers whether the battery can be claimednice to have
Run your prompt and the replies land here.
Klippy's citations are numbered passages in plain text, which works with any model. With Claude, the native upgrade is to pass passages as search_result content blocks: Claude then returns structured citations that point at the exact source.
4. The retrieval lock
In course C, a web page could give orders, so reading one cut Klippy's private tools. A saved page or file is the same untrusted text, just stored. So the same pattern applies, with one difference: reading your own notes is still fine, but nothing may write once saved content is in the chat. Otherwise a saved page could say "remember that my password is…" or "save this link", and Klippy would obey.
It's enforced in three places, as before. First, the whole chat:
Then what each step is offered, in prepareStep:
And, behind the offer, what may run. searchNotes flips turn.retrieved before it searches, and every writing tool is wrapped to refuse after that:
Copy the full chatTools and afterPage from the tag. activeTools only limits what the model is offered; the throw is what holds if a model calls a tool anyway. The lock is lifted by /clear.
5. Run it
wrangler dev, then the chat (the Workers AI fallback answered, not Claude):
Read it honestly:
Pip: found and cited.
Tokyo: the search did return passages, including the Lisbon flight at
[2]. The model rightly said Tokyo isn't there.The password: the lock working, clumsily explained. With a search result in the chat,
rememberisn't offered or built, and the fallback model made up a reason. To write again,/clear.
6. Prove it
Copy test/retrieval.spec.ts, test/loops.spec.ts and test/ingest.spec.ts. The tests try the lock from every side: a write the passage asks for is refused, the next turn starts read-only, a forced write after a search throws, and a page still removes the search tools. Ours: 20 files, 94 tests passed.
Using course D's web app too? Copy apps/web/app/ui/sources.tsx and message.tsx: under each answer, it lists [n] title for every passage.
Your code matches tag e-07 (changes from e-06).