A fast, local-first explorer for Facebook Messenger archive exports.
Browse, search and measure your own message history — without any of it leaving your machine.
Point it at your export folder and it becomes a real client for your own history: browse every conversation, search all of it in milliseconds with matching that understands Vietnamese accents, measure how the two of you actually talk, and count how often any word gets used.
Nothing leaves your machine. There is no database, no account, and no network call
outside localhost — the archive is read from disk into memory at boot and served
by a single Node process.
Every screenshot here is taken from
examples/sample-archive, which is entirely fictional and generated by a committed script. No real messages appear anywhere in this repository.
A Messenger export is small — a hundred thousand messages is roughly ten megabytes of text. That fits comfortably in memory, so Threadglass parses the whole archive at startup and answers every request from RAM.
The consequences are worth stating plainly:
| Decision | Reason |
|---|---|
| In-memory index, no SQLite/FTS5 | A linear scan over pre-folded strings resolves 40k messages in under 10 ms. A native better-sqlite3 build is a real portability risk on Windows and buys nothing at this scale. |
| Zero native dependencies | npm install works on any platform with Node 20+; no compiler, no Python, no post-install build step. |
| Diacritics folded once, at ingest | The corpus is Vietnamese. chao must find chào, chảo and cháo. Folding per message at boot beats folding per comparison at query time, and folding is length-preserving so match offsets still index into the original for highlighting. |
| Whole thread sent in one response | Even the largest conversation is a few megabytes over loopback. Holding it client-side makes find, jump-to-message and day grouping instant instead of round-trips. |
| Charts hand-built in SVG | A charting library adds ~100 kB and its own visual language. These inherit the app's design tokens, so the dashboard looks like one product. |
Startup on a 103-conversation / 40,599-message archive: ~130 ms.
- Node.js 20.12 or newer (developed on 24.x)
- A message export directory — or nothing, to try the bundled sample
git clone https://github.com/<you>/threadglass.git
cd threadglass
npm install
npm run devThat runs against examples/sample-archive, a small fictional export, so you can see
the app working immediately.
To open your own messages, use the app — click the archive icon in the left rail (or "Open yours" on the sample banner), then paste a path or browse to the folder. The choice is remembered, so you only do it once. No environment variable, no restart.
Setting THREADGLASS_ARCHIVE_DIR still works and is what a container or a scripted run
should use; a folder chosen in the app takes precedence on the next launch.
npm run dev starts the API on http://127.0.0.1:8787 and the client on
http://127.0.0.1:5173 with /api and /media proxied.
npm run build
npm start # http://127.0.0.1:8787 serves API, media and clientdocker build -t threadglass .
docker run --rm -p 8787:8787 \
-v "/path/to/messages:/archive:ro" \
-e THREADGLASS_ARCHIVE_DIR=/archive \
-e HOST=0.0.0.0 \
threadglassThe archive is mounted read-only; Threadglass never writes to it.
Threadglass detects the layout automatically — there is nothing to configure.
Facebook's own export, from Settings → Your information → Download your information
(choose JSON, then select Messages). Point Threadglass at the messages folder:
your_facebook_activity/messages/ ← THREADGLASS_ARCHIVE_DIR
inbox/
alicetran_9f2c/
message_1.json ← long threads split across numbered parts
message_2.json
photos/ · videos/ · audio/ · gifs/
archived_threads/ · message_requests/
stickers_used/
Facebook's JSON is awkward in ways Threadglass handles for you:
- Text is mojibake — UTF-8 bytes written back out as Latin-1, so
Nguyễnarrives asNguyá»…n. Every name, message and reaction is repaired on load. - A long conversation is split across
message_1.json,message_2.json, …, each repeating the header. Parts are merged and re-sorted. - Attachment URIs are written relative to the export root, several levels above the folder you point at. They are re-anchored so they resolve.
- Every message is typed
"Generic"; a useful kind is derived from the payload.
The layout produced by several third-party Messenger exporters — one file per
conversation, one shared media/ folder:
messages/ ← THREADGLASS_ARCHIVE_DIR
Alice Trần_1.json
Bob Nguyễn_2.json
media/
3f2b91c4-....jpeg
- Messages are re-sorted by timestamp; no export is reliably ordered.
"uri": "Failed to download media"is the sentinel Facebook writes when an attachment could not be fetched. Those render as an explicit note, not a broken image.- Files that fail to parse are skipped and reported in
GET /api/meta, never fatal. - The archive owner is detected as the one participant present in every conversation.
- WebP attachments are treated as stickers and rendered without bubble chrome, which
matches how Messenger stores them. Override by editing
STICKER_EXTif your export differs.
GET /api/meta reports which layout was detected (facebook-dyi or normalized),
which is the first thing to check if something looks wrong.
Every setting is an environment variable. Copy .env.example to .env — both
npm run dev and npm start load it if present.
| Variable | Default | Purpose |
|---|---|---|
THREADGLASS_ARCHIVE_DIR |
examples/sample-archive |
Path to your export. Usually unnecessary — pick the folder in the app instead. |
THREADGLASS_STATE_FILE |
~/.threadglass/settings.json |
Where the chosen archive and recent list are remembered. |
THREADGLASS_OWNER |
auto-detected | Override the detected archive owner. |
HOST |
127.0.0.1 |
Bind address. Use 0.0.0.0 in a container. |
PORT |
8787 |
HTTP port. |
THREADGLASS_SERVE_CLIENT |
on when NODE_ENV=production |
Serve web/dist from the API process. |
THREADGLASS_CORS_ORIGIN |
same-origin only | Allowed API origin. Leave unset unless you know you need it. |
LOG_LEVEL |
info |
Pino level, or silent. |
Conversations. Virtualized message list that stays smooth in a thread of 35,000+ messages — only the visible rows exist in the DOM. Day dividers, message bursts grouped by sender, reactions, inline photo/video/voice attachments with a lightbox, and timestamps that appear on hover instead of cluttering every bubble.
Search. Every message in the archive, with highlighted excerpts. Filter by conversation, message type, and date order. Results group by conversation and click through to the exact message in context.
Three matching modes, shared by search, the conversation find bar and the Word Lab:
| Mode | Rule |
|---|---|
| Anywhere | Matches anywhere inside a message. |
| Whole word | Matches the word on its own, and tolerates stretched spellings: yêu finds yêu, anh yêu em and yêuuu, but not yêuthương. |
| Regex | Your pattern, applied verbatim. |
Both non-regex modes use smart diacritics: type without accents and they are
ignored (yeu finds both yêu and yếu); type the accents and they are respected
(yêu no longer matches yếu). This mirrors the smart-case convention, and matters
here because folding collapses distinct Vietnamese words onto the same letters.
The Word Lab, showing why the rules exist: two different Vietnamese words that plain accent-folding would merge into one number.
Word Lab. The feature search cannot provide: pick words or phrases and get total occurrences, how many messages contain them, who says them and in what proportion, usage per month and per day, and — at archive scope — which conversations use them most. Compare up to eight terms at once.
Conversation insights. A head-to-head read on any thread: message and word counts, average message length, median reply time, who starts conversations after a lull, who double-texts, reactions given versus received, hour-of-day and weekday rhythm, monthly volume, most-used words and two-word phrases, top emoji and reactions, plus milestones (busiest day, longest daily streak, longest silence).
Archive overview. Totals across all conversations, sent-versus-received balance, volume by month and by day, attachment breakdown by kind, busiest conversations, and the words and emoji you use most anywhere.
Command palette. ⌘K / Ctrl K jumps to a conversation by name or straight to a
message anywhere in the archive.
Export. Any conversation as JSON, CSV (BOM-prefixed so Excel reads UTF-8), Markdown or plain text.
| Shortcut | Action |
|---|---|
⌘K / Ctrl K |
Command palette |
⌘F / Ctrl F |
Find in the open conversation |
↵ / ⇧↵ |
Next / previous match |
⌘↵ in the palette |
Open full search results |
Esc |
Close palette, find bar or lightbox |
All responses are JSON. The server is read-only; there are no mutating endpoints.
| Endpoint | Description |
|---|---|
GET /api/meta |
Owner, counts, date range, ingest time, detected export format, skipped files. |
GET /api/health |
Liveness plus message counts. |
GET /api/threads?q=&sort= |
Conversation summaries. sort: recent | messages | name. |
GET /api/threads/:id |
One conversation summary. |
GET /api/threads/:id/messages?from=&to=&around=&limit= |
Message window by index. |
GET /api/threads/:id/analytics |
Full statistics for one conversation. |
GET /api/threads/:id/export?format=json|csv|md|txt |
Download a conversation. |
GET /api/analytics/overview |
Archive-wide dashboard. |
GET /api/search?q=&mode=&thread=&sender=&kinds=&from=&to=&order=&limit=&offset= |
Full-archive search. |
GET /api/count?terms=a,b&thread=&mode=&sender= |
Term counts, per sender and per month. |
GET /media/* |
Attachments, resolved against the open archive. Supports range requests. |
GET /api/archive |
Current archive: path, detected format, counts, recents. |
POST /api/archive |
Open a different archive. Body: { "path": "..." }. |
GET /api/archive/browse?path= |
List sub-folders, flagging likely exports. Loopback binds only. |
mode is substring (default), word, or regex. Invalid regular expressions are
reported in the response body rather than raised as a 500.
curl 'http://127.0.0.1:8787/api/search?q=chao&limit=5'
curl 'http://127.0.0.1:8787/api/count?terms=hihi,sorry'server/
src/
main.ts boot: config → ingest → listen
app.ts Fastify instance, static mounts, SPA fallback
app-context.ts shared state and derived-analytics caches
config.ts environment configuration
ingest/archive-loader.ts read and normalize the export
domain/
types.ts raw export shapes and the in-memory model
text-utils.ts diacritic folding, tokenizing, matching
search-engine.ts archive-wide search
analytics.ts per-thread and archive statistics
term-counter.ts Word Lab counting
thread-exporter.ts JSON / CSV / Markdown / text output
routes/ HTTP surface
test/ vitest suites (75 tests)
web/
src/
app.tsx shell, sections, keyboard shortcuts
components/ views, charts, message rendering
lib/ API client, formatters, hooks
styles/ design tokens, glass material, layout
The interface follows Apple's Liquid Glass language. In practice that means four
things, defined once in web/src/styles/glass.css and reused everywhere:
- Translucent fills in three tiers — thin for controls, regular for panels, thick for modals and the rail — light enough that the backdrop still tints each surface. An opaque veil stops reading as glass no matter how much blur sits behind it.
- Backdrop blur with a saturation boost, which is what makes the background read as refracted rather than merely faded.
- A specular edge — a 1px gradient border, bright along the lit side and dark along the shaded one — that gives each slab thickness.
- Layered ambient shadows for depth against the moving colour field behind everything.
Supporting choices: continuous large corner radii, Apple's
cubic-bezier(0.32, 0.72, 0, 1) spring easing, the system font stack with optical
tracking that tightens as type grows, and a light/dark/system appearance toggle.
prefers-reduced-motion disables the drifting backdrop and every transition, and
there is a @supports fallback that raises fill opacity where backdrop-filter is
unavailable so text keeps its contrast.
npm run dev # API with watch + Vite dev server
npm test # vitest, 75 tests
npm run typecheck # tsc --noEmit across both packages
npm run build # web bundle then server compileTests build a temporary archive on disk — including an unsorted thread, a failed attachment, an unsent placeholder and a deliberately malformed file — and run the real loader against it, so schema assumptions are exercised end to end rather than mocked.
Threadglass reads your archive and serves it to your own browser. It makes no outbound requests of any kind — no telemetry, no analytics, no update check, no CDN or web font — and it never writes to the archive directory.
The only things it stores are the archive path and a short recents list in
~/.threadglass/settings.json, and your light/dark preference in localStorage. No
message content is written anywhere.
It binds to 127.0.0.1 by default. If you change HOST, anyone who can reach that
address can read every message in the archive, with no credentials — see
SECURITY.md.
Issues and pull requests are welcome — see CONTRIBUTING.md.
Please never attach real message data to an issue or PR. The bundled sample archive exists so there is always a safe way to demonstrate a problem.
Threadglass binds to loopback and has no authentication, because it assumes the only person who can reach it is you. Before exposing it to a network, read SECURITY.md — it describes the threat model and how to report a vulnerability privately.
MIT. See LICENSE.
Phu Nguyen — HCMC, VN





{ "participants": ["You", "Alice Trần"], "threadName": "Alice Trần_1", "messages": [ { "senderName": "Alice Trần", "text": "chào bạn", "timestamp": 1777960838385, // epoch milliseconds "type": "text", // text | media | link | placeholder "isUnsent": false, "media": [{ "uri": "./media/3f2b91c4-....jpeg" }], "reactions": [{ "actor": "You", "reaction": "❤" }] } ] }