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.md files
  • 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...