Archiving Claude Code Conversations to Hugo
AI Disclosure: This post was written by AI, based on the actual implementation. References to “I” refer to the AI author, not the site owner.
Every Claude Code session is a JSONL file sitting in ~/.claude/projects/. Across 17 projects I had 156 sessions — debugging logs, architecture decisions, implementation plans — all invisible unless you go digging through raw JSON. So I built a Python script that batch-converts them into browsable Hugo content, grouped by project.
The problem
Claude Code stores every session as a JSONL transcript under ~/.claude/projects/<encoded-path>/. Each line is a JSON object with a type (user, assistant, system), a message, and a timestamp. The directory name is the project’s filesystem path with slashes replaced by dashes:
~/.claude/projects/
├── -home-devops-code-turfops-run/ # 87 sessions
│ ├── 440afa79-...-....jsonl
│ ├── ec6a15f8-...-....jsonl
│ └── ...
├── -home-devops-code-websites-dustinlee-dev/ # 11 sessions
└── -home-leed-code-mcp-betfair/ # 6 sessions
Each project also has a three-word slug (keen-jumping-turing, partitioned-beaming-lemur) shared across all sessions in that project. Plan files live separately in ~/.claude/plans/<slug>.md.
I wanted all of this rendered as Hugo content — grouped by project, with plans embedded, and each conversation showing timestamped chat bubbles.
The structure
Hugo nested sections need _index.md (branch bundle) at each level. The archive script generates this automatically:
blog/content/conversations/
├── _index.md # "Conversations" section
├── turfops-run/
│ ├── _index.md # Project page + embedded plan
│ ├── implement-the-following-plan-440afa79/
│ │ └── index.md # Individual conversation
│ └── can-you-check-why-the-morning.../
│ └── index.md
├── dustinlee-dev/
│ ├── _index.md
│ └── ...
The top-level /conversations/ page shows project cards with session counts. Clicking a project shows its individual conversations sorted by date.
The script
The conversion script supports three modes:
# Single file
python3 blog/scripts/archive-conversation.py session.jsonl
# All sessions in one project
python3 blog/scripts/archive-conversation.py --project ~/.claude/projects/-home-devops-code-turfops-run/
# Everything
python3 blog/scripts/archive-conversation.py --all
Or via Make:
make blog-archive-all
What it does
Project name derivation strips the encoded path prefix to get a human-readable name. -home-devops-code-websites-dustinlee-dev becomes dustinlee-dev. Known projects get display names — turfops-run renders as “TurfOps.run” in the section heading.
Auto-titling uses the first line of the first user prompt (truncated to 60 characters) instead of the session UUID. So instead of 440afa79-1234-5678-abcd-... you get “Implement the following plan” or “Can you check why the morning digest did send this”.
Plan embedding finds the matching plan file in ~/.claude/plans/ by reading the session slug from the JSONL, then wraps the plan content in a collapsible details shortcode on the project’s _index.md.
Shortcode escaping is the trickiest part. Hugo’s shortcode parser chokes on shortcode delimiter patterns inside content, and backslashes near quotes in shortcode attributes cause unterminated string errors. The script:
- Breaks shortcode delimiters with zero-width spaces (
{{+\u200b+{<) - Strips backslashes and replaces double quotes with single quotes in prompt attributes
- Truncates prompts to 200 characters (single-line only)
The JSONL format
Each line in a session file looks roughly like:
{"type": "user", "message": {"content": "fix the login bug"}, "timestamp": "2026-02-18T06:48:51.924Z", "sessionId": "440afa79-...", "slug": "partitioned-beaming-lemur"}
{"type": "assistant", "message": {"content": [{"type": "text", "text": "I'll look at the authentication..."}]}, "timestamp": "..."}
User messages have string content. Assistant messages have an array of content blocks (text, tool_use, etc.) — the script extracts only text blocks.
The script pairs consecutive user/assistant messages into ai shortcodes. Orphan assistant messages (no preceding user prompt) render as response-only blocks without a prompt bubble.
The Hugo templates
List layout
The conversations list template handles two levels using Hugo’s .Sections and .Pages:
{{- if .Sections }}
{{/* Top-level: show project cards */}}
{{- range .Sections }}
<a class="post-card project-card" href="{{ .Permalink }}">
<h2>{{ .Title }}</h2>
<span class="session-count">{{ len .Pages }} sessions</span>
</a>
{{- end }}
{{- end }}
{{- if .Pages }}
{{/* Project-level: show conversation cards */}}
{{- range .Pages.ByDate.Reverse }}
<a class="post-card" href="{{ .Permalink }}">
<h2>{{ .Title }}</h2>
<time>{{ dateFormat ":date_medium" .Date }}</time>
</a>
{{- end }}
{{- end }}
At /conversations/ you see project cards. At /conversations/turfops-run/ you see that project’s sessions.
Details shortcode
Plans are embedded as collapsible blocks using a custom shortcode:
{{- $summary := .Get "summary" | default "Details" -}}
<details class="conversation-details">
<summary>{{ $summary }}</summary>
<div class="details-content">
{{ .Inner | markdownify }}
</div>
</details>
This avoids needing unsafe: true in the Goldmark config — the HTML is generated by the shortcode, not raw in the markdown.
AI chat shortcode
Each exchange renders as a chat bubble pair. The prompt parameter is optional — when omitted, only the response bubble appears:
{{- $prompt := .Get "prompt" -}}
<div class="ai-chat">
{{- if $prompt }}
<div class="ai-prompt">
<div class="ai-bubble ai-bubble-user">{{ $prompt | markdownify }}</div>
</div>
{{- end }}
<div class="ai-response">
<div class="ai-bubble ai-bubble-model">{{ .Inner | markdownify }}</div>
</div>
</div>
The numbers
Running make blog-archive-all across all 17 projects:
- 153 conversations archived (3 sessions skipped — empty)
- 17 project sections with
_index.mdfiles - 12 projects with embedded implementation plans
- 239 total pages in the Hugo build
- Build time: ~1.5 seconds with
--buildDrafts
All conversations are generated as draft: true so they don’t appear on the live site until reviewed.
What I’d change
The auto-titling could be smarter. Many sessions start with “Implement the following plan” because that’s how plan mode works — the plan text is pasted as the first prompt. A future improvement would be to scan deeper into the prompt for a more descriptive title, or pull from the plan’s heading.
The two turfops-run project directories (one under /home/devops/, one under /home/leed/) merge into a single section since they derive to the same project name. This is the correct behaviour for this case but could be surprising for genuinely different projects that happen to share a name.
Comments
Loading comments...