claude-code-chat-browser reads JSONL session files written by Claude Code under ~/.claude/projects/ and serves them through a JSON HTTP API to a single-page web UI, with a parallel CLI export tool. The app is read-only toward ~/.claude/ — it never writes session data back to that tree.
┌─────────────────────────────┐
│ ~/.claude/projects/ │
│ <project>/*.jsonl │ (read-only data source)
└──────────────┬────────────┘
│
┌─────────────────────────┼─────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ session_path │ │ jsonl_parser │ │ exclusion_rules │
│ list_projects │ │ session_peek │ │ load + match │
│ list_sessions │ │ tool_dispatch │ └────────┬─────────┘
│ safe_join │ │ jsonl_helpers │ │
└────────┬────────┘ └──────────┬──────────┘ │
│ │ │
└────────────┬───────────┴────────────────────────┘
│
▼
┌────────────────────────────┐
│ api/ │
│ projects · sessions │
│ search · export_api │
│ error_codes │
└─────────────┬──────────────┘
│ Flask blueprints
▼
┌────────────────────────────┐
│ app.py — create_app() │
└─────────────┬──────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ static/ │ │ scripts/export.py │
│ index.html + js │ │ (CLI, uses utils) │
└──────────────────┘ └──────────────────┘
| Layer | Responsibility | Key modules |
|---|---|---|
| Data discovery | Resolve ~/.claude/projects/, list projects and sessions, prevent path traversal |
utils/session_path.py |
| Parsing | JSONL → session dict (messages, metadata, tool rendering) | utils/jsonl_parser.py, utils/tool_dispatch.py, utils/session_peek.py, utils/jsonl_helpers.py |
| Filtering | Exclude sensitive sessions via rules file | utils/exclusion_rules.py |
| Statistics | Aggregates for API and exporters | utils/session_stats.py |
| Export — Markdown | Session → YAML-frontmatter Markdown | utils/md_exporter.py |
| Export — JSON | Session → JSON string for download | utils/json_exporter.py |
| Export — state | Incremental export checkpoints on disk | utils/export_state_store.py, api/export_api.py |
| HTTP | Routes, validation, error envelope | api/*.py, api/error_codes.py |
| App factory | Blueprint registration, rules loading, SPA static route | app.py |
| Frontend | Hash-routed UI, markdown render, shared state | static/index.html, static/js/ |
| CLI | Same export semantics as bulk API, no HTTP | scripts/export.py |
- Browser loads
GET /→static/index.html. - SPA calls
GET /api/projects→list_projects()+quick_session_info()for titled counts. - User opens a project →
GET /api/projects/<name>/sessions→ fullparse_session()per file, exclusion filter, summary rows. - User opens a session →
GET /api/sessions/<project>/<id>→ full session JSON for the message panel. - Optional:
GET /api/sessions/.../statsfor sidebar metrics without loading all messages. - Search:
GET /api/search?q=...scans all projects (brute force). - Export:
POST /api/exportorGET /api/export/session/...→ Markdown/zip via exporters; state file updated on successful bulk export.
In utils/tool_dispatch.py, tool results are classified through _parse_tool_result, a predicate-ordered dispatch table (not a simple if tool_name == ... chain). Order is load-bearing: the first matching predicate wins. Tests in tests/test_jsonl_parser.py and tests/test_real_session_fixtures.py guard ordering regressions.
When adding a new tool renderer:
- Add a
(predicate, builder)pair to_TOOL_RESULT_DISPATCHinutils/tool_dispatch.py, preserving existing predicate order unless you also update fixtures and ordering tests (tests/test_jsonl_parser.py,tests/test_real_session_fixtures.py). Order is not “specific before generic” in general — the first match wins.is_task_message_tool_resultis the intentional broad-before-narrow exception (task_idormessagebefore retrieval/completed/async). - Add or extend a JSONL fixture under
tests/fixtures/(especially for overlaps with existing predicates). - Run
pytest tests/test_jsonl_parser.py tests/test_real_session_fixtures.py -v.
Bulk export (POST /api/export) is stateful. State lives in ~/.claude-code-chat-browser/export-state.json (see EXPORT_STATE_FILE in utils/export_state_store.py).
since mode |
Behavior |
|---|---|
all |
Export all eligible sessions; update per-session mtimes in state |
last |
Export sessions active on the latest UTC calendar day in history |
incremental |
Export only sessions newer than last recorded mtime per id |
Writes are atomic (temp file + os.replace) under a lock from _state_lock().
If zero sessions match, the API returns 422 with EXPORT_NOTHING_TO_EXPORT and echoes since — not an empty zip.
GET /api/export/state reads the same file without mutating it.
At startup, create_app() loads rules from --exclude-rules or the default path into app.config["EXCLUSION_RULES"]. is_session_excluded() is applied on list, detail, search, and export paths so filtered sessions never appear in the UI or downloads.
The UI is a hash-routed SPA with ES modules under static/js/:
app.js— routing and bootprojects.js,sessions.js,search.js,export.js— route handlersrender/registry.js— tool dispatch registry for session UI:TOOL_USE_RENDERERSandTOOL_RESULT_RENDERERSmap tool name /result_type→ render function (one module per type underrender/tool_use/andrender/tool_result/). Parallels backendutils/tool_dispatch.py(backend uses ordered predicates; frontend uses direct key lookup + fallback).static/tool_types.json— generated manifest of backend tool-use names (python scripts/gen_tool_types_manifest.pyfromKNOWN_TOOL_TYPES). Fetched non-blocking at boot byrender/tool_types_manifest.js(void initToolTypesManifest()inapp.js), which cross-checksTOOL_USE_RENDERERSand logsconsole.warnon drift.shared/markdown.js— markdown + DOMPurify sanitization (do not render raw LLM HTML)shared/state.js,shared/utils.js,shared/theme.js— shared UI state and helpers
sessions.js keeps workspace/session orchestration and message bubbles; tool cards delegate to render/registry.js.
No bundler step — modern browsers load modules directly. Frontend unit tests use vitest + jsdom (npm test), including static/js/render/registry.test.js for registry wiring and renderer escaping.
create_app() registers an @app.after_request hook that sets a Content-Security-Policy header on every Flask response. The policy is defined as CSP_POLICY in app.py:
| Directive | Sources | Notes |
|---|---|---|
default-src |
'self' |
Fallback for unspecified fetch types |
script-src |
'self', https://cdnjs.cloudflare.com |
Self-hosted JS (e.g. theme-init.js, ES modules) plus SRI-pinned CDN scripts in index.html |
style-src |
'self', 'unsafe-inline', https://cdnjs.cloudflare.com |
'unsafe-inline' required for highlight.js theme inline styles and the app's own inline style attributes (e.g. hamburger display:none in index.html, layout tweaks in JS templates). Dropping highlight.js alone does not remove this need; nonces are the future tightening path |
img-src |
'self', data: |
Session images and data URLs |
connect-src |
'self' |
API fetch calls to same origin |
font-src |
'self' |
Local fonts only |
object-src |
'none' |
Block plugins / <object> embeds (no plugin use in this app) |
form-action |
'self' |
Restrict form submissions to same origin |
base-uri |
'self' |
Restrict <base> tag injection |
frame-ancestors |
'none' |
Prevent clickjacking via iframes |
Keeping CDN sources in sync: when adding or bumping a CDN asset in static/index.html, update both the SRI integrity hash and CSP_POLICY if the origin changes (today all CDN assets use cdnjs.cloudflare.com). Recompute SRI hashes against the live CDN payload when bumping highlight.js — tests/test_hljs_theme_consistency.py cross-checks index.html, hljs-theme-init.js, and theme.js stay in sync with each other (not the live CDN, which would be flaky in CI). Theme-init scripts were externalized to static/js/theme-init.js and static/js/hljs-theme-init.js so script-src does not require 'unsafe-inline'. Navbar and route UI handlers use addEventListener instead of inline onclick attributes for the same reason.
.github/workflows/ci.yml runs on push/PR:
prod-install-smoke— productionrequirements.txtboots the apppytest— full Python suite with coverage gate onapi/+utils/integration-tests— API integration subset + coverage artifactjs-tests—npm ci+ vitest
- Not multi-user — no authn/authz; single local operator.
- Not a writeback tool — never modifies
~/.claude/. - Not a search engine —
/api/searchis O(sessions × messages); fine for personal history, not for large multi-tenant indexes. - Not a versioned public API — no semver or OpenAPI contract yet; see
docs/api-reference.mdas the human contract.