Searching & browsing Claude conversations
We added a user interface layer to our Chrome extension for automatically backing up Claude conversations.
After using my Chrome extension that automatically backs up Claude conversations for a little while, I started wanting something more interactive than just a folder of JSON files. The original design already solved my core problem: having local copies of all my Claude chats for permanent backup, grep-based searching1, and other CLI or programmatic tools.
But when I just want to find something and then read or continue the conversation, it’s pretty annoying to switch contexts between the filesystem and browser.
So I decided to add a browsing interface directly within the extension, letting me search, filter, and browse my backed-up conversations without leaving Chrome. Here’s the end result:
Chrome extensions can’t read downloaded files
To build a browsing interface, I knew we’d need to rethink the storage layer first.
Chrome’s security model doesn’t let extensions read the contents of files on your disk — even if they were created by the same extension and live in the downloads directory.
I started by adding the extension’s Github repo to a new Claude conversation and asking:
I'd like to enhance this chrome extension with a “web UI” that allows you to browse through the list of all your locally-cached conversations. I don't need to provide a way to parse and render the JSON content of each conversation — just a listing of all conversations that you can scroll through ideally with titles, start date/time, last update date/time, and ideally full-text searchable. Can you help me with that? The conversations are stored to disk in the downloads dir, and we maintain a backup state with {uuid:updated_timestamp} mappings.
First, Claude thought for two and a half minutes. In that thinking block, it:
Did great on the overall scaffolding (communication between the
background.js
worker and a newbrowser.js
component, a lovelybrowser.html
UI, navigation added to thepopup.js
component)Built out logic to open, read, and parse our
conversation_index.json
file that’s already being generated and saved to disk for browsing, ordering, and search (which will not work, because of that security boundary)Proposed an initial “search within conversation” implementation using
chrome.downloads.search
andfetch(download.filename)
Spotted most of the fundamental issue (“However, I'm not sure if we can directly use the fetch API to access local files due to browser security restrictions. Let's modify our approach again”)
Proposed an alternative implementation using
chrome.downloads.search
andchrome.downloads.open
— i.e., opening each JSON conversation with a file viewer or new Chrome tab — which should work, but wouldn’t actually let the extension use the contents of the file at all to perform searchesNever spotted that the same problem would apply to reading and parsing
conversation_index.json
When I pointed out that this still wouldn’t work because of those fundamental security boundaries, we got into an interesting back-and-forth. Claude kept proposing increasingly elaborate & unworkable ways to circumvent it — don’t erase download history (irrelevant!), keep all saved conversations in memory (impractical!), re-fetch all conversations directly from the Anthropic API when needed (extremely impractical, impolite, and purpose-defeating!), adjust extension permissions to grant filesystem access (not actually possible anymore!), or prompt the end user to drag and drop the downloaded files into an <input type=file>
so that they can be accessed. (I’ll concede that this last one would work and is actually pretty clever, but it’s obviously a terrible user experience.)
Claude isn’t great with platform specifics
As an aside, this is a pattern I’ve noticed quite a lot with Claude: it’s great at general coding and thought partnership, but quite a lot worse at keeping the critical nuances of specific platforms front-and-center in its thinking. For example:
When pairing with Claude on Voice Claude or other projects with Durable Objects, it often misunderstands crucial details about Durable Objects’ lifecycle, storage layers, or even just how to get them involved in an HTTP request.
When trying to work through LLM cost modeling for multi-turn conversations, it often gets the math totally wrong (in ways that mirror my own initial naïve intuitions); and when designing tools, it often misunderstands the control flow.
When working on simpler Cloudflare Workers projects, it tends to propose older-style Service Worker implementations rather than ES Modules, but then often conflates the two as we go on.
When building Vercel full stack applications, it often loses track of which platform features are standard, which are available but require extra configuration, and which are specific to Next.js stacks on Vercel. For example, it will often give me a Vercel Function on a path like
/api/objects/[id].js
without any routing configuration, and then realize the error when I push back.When building Vercel Edge Middleware, it tends to produce code that just doesn’t work at all, and gets stuck quickly when trying to debug error messages or diagnose failures. (To be fair, I do too.)
When building web components, it often introduces bugs that are specific to whether we’re using shadow DOM features
Interestingly this doesn’t seem to be a general knowledge gap or a matter of cutoff dates.2
Instead it feels like an “attention to detail” thing. The more Claude is focusing on what code to write, the less it’s able to remember the specific platform the code will live in.
I get better results if I’m quite prescriptive about approach, deliberate about when I’m introducing context, and rigorous about keeping platform details front-of-mind with direct questions.
Rethinking the storage layer
I eventually gave Claude the answer: maintain data in two places. Keep writing JSON files for permanent backup while also storing everything in IndexedDB where the extension could actually work with it.
My first instinct was to simply store entire conversation objects:
const conversationsStore = db.createObjectStore('conversations', {
keyPath: 'uuid'
});
const tx = db.transaction(['conversations'], 'readwrite');
const store = tx.objectStore('conversations');
/* persist one big object per conversation,
including the array of conversation.chat_messages */
await store.put(conversation);
But this presents several problems:
Size Constraints: Some of my conversations are 800KB+ of JSON — not a problem for IndexedDB's overall capacity, but probably inefficient for querying.
Search Limitations: IndexedDB can't easily search within nested objects. We'd need to load entire conversations into memory to check if they contain a search term.
Filtering Complexity: If we wanted to filter by specific content types (thinking blocks vs. text replies, attachment content) or sender, we'd need to add even more ad-hoc logic.
Data Format Coupling: I was motivated to stash objects without any processing in part to future-proof my implementation against changes in Anthropic’s JSON representations. (This is consuming unofficial APIs after all.) But if we do want to have any intelligent filtering by content type, sender, star status, or other logical properties, we’re better off explicitly representing those in our code and storage — and just dealing with breaking changes if and when they occur. Otherwise we’re no less tightly coupled; we’d just lack any codification of our assumptions.
So Claude ultimately convinced me to normalize the data across two tables:
Conversations: Metadata about each conversation (title, star status, start date, latest message date, etc.)
Messages: Individual messages from those conversations, with references back to the parent conversation
In this approach, we loop over all chat turns, extract and normalize each item from the turn’s content array and top-level attachments, and store with details like sender, timestamp, position, and thinking
vs text
vs tool_use
type.
const messagesStore = db.createObjectStore('messages', {
keyPath: 'id',
autoIncrement: true
});
const entries = [];
const baseTimestamp = message.created_at || message.updated_at;
const sender = message.sender || 'unknown';
(message.content || []).forEach((contentItem, index) => {
let entry = {
conversation_uuid: conversationUuid,
message_uuid: message.uuid,
sender: sender,
timestamp: baseTimestamp,
position_index: index,
content_type: contentItem.type || 'unknown'
};
if (contentItem.text) {
entry.text = contentItem.text;
} else if (contentItem.thinking) {
entry.text = contentItem.thinking;
}
if (entry.text) {
entries.push(entry);
}
});
(message.attachments || []).forEach((attachment, index) => {
entries.push({
conversation_uuid: conversationUuid,
message_uuid: message.uuid,
sender: sender,
timestamp: baseTimestamp,
position_index: 100 + index, // Offset to separate from main content
content_type: 'attachment',
text: attachment.extracted_content,
attachment_info: `${attachment.file_name || 'unnamed'} (${attachment.file_type || 'unknown type'})`
});
});
for (const entry of entries) {
await messagesStore.put(entry);
}
This lets us provide options for fine-tuning search results based on that metadata:
Full text search with IndexedDB
After all that, I was a little surprised to discover that Claude’s initial search implementation didn’t seem to use any actual database features. It just loaded all messages into memory and did all the filtering with Javascript logic.
Digging in a little more I learned that IndexedDB doesn't actually have any true full-text search capabilities (Joshua Bell has a handy-looking full text search demo) and even querying out-of-the-box indexes for exact matches or ranges involves some lower-level coding than I was hoping for.
So, for this round, I settled for Claude’s in-memory implementation, with just enough logic to make sure we could search against multiple terms:
const searchTerms = query.split(/\s+/).filter(term => term.length > 1);
const request = messagesStore.getAll();
let matchingMessages = request.result.filter(message => {
if (!message.text) return false;
const messageText = message.text.toLowerCase();
return searchTerms.every(term => messageText.includes(term));
});
Longer term I’ll probably want to make more efficient use of IndexedDB indexes and cursors, but so far this doesn’t seem to be giving me any problems.
Building the UI
Meanwhile, Claude built a simple but effective browser interface. I had no notes, it was great on the first try:
The UI includes:
Cards displaying conversation metadata including start time, end time, and chat size
A “backup now” button
A search box that filters as you type
Checkboxes to filter by content type and sender, which only appear when you’re searching
A snippet view showing the context around the top matches
Each card is clickable and takes you directly to the conversation on Claude.ai, so you can quickly read and resume a conversation once you’ve found it.
What’s next?
There are several directions I'm considering for improvements if & when I want to do more here:
Conversation annotations: allow users to tags conversations, group them into folders, or add searchable notes
AI in the browser: leverage Chrome’s experimental in-browser
window.ai
for unstructured search and summarization (which I’ve been learning about from Raymond Camden’s excellent posts)
But for now I’m back to being happy with this! The extension is available on GitHub if you want to try it yourself or contribute.
Though grep-based searching is actually strangely inconvenient when you want to find all files that match two or more terms, and you just want the filenames & not all the context. This is the best I’ve been able to do; is there some other tool I should be using instead?
grep -l term1 * | xargs grep -l term2 | xargs grep -l term3
If I ask directly in a new conversation “can a Chrome extension access the contents of filesystem files via fetch(file://)
or any other mechanism as of 2025?” Claude responds directly and correctly: “As of my knowledge cutoff in October 2024, Chrome extensions cannot directly access local filesystem files. This capability was deliberately restricted for security reasons.”