Skip to content

Instantly share code, notes, and snippets.

@simonw
Created October 18, 2025 18:22
Show Gist options
  • Save simonw/d225134d348435c8106070cb74c78888 to your computer and use it in GitHub Desktop.
Save simonw/d225134d348435c8106070cb74c78888 to your computer and use it in GitHub Desktop.
╭─── Claude Code v2.0.22 ──────────────────────────────────────────────────────╮
│ │ Tips for getting started │
│ Welcome back Simon! │ Run /init to create a CLAUDE.md file with ins… │
│ │ ────────────────────────────────────────────── │
│ ▗ ▗ ▖ ▖ │ Recent activity │
│ │ No recent activity │
│ ▘▘ ▝▝ │ │
│ │ │
│ Sonnet 4.5 · Claude Max │ │
│ /private/tmp/geo │ │
╰──────────────────────────────────────────────────────────────────────────────╯
> That full.json file has a full transcript from a deep research session. Build an HTML page that loads that via
fetch() and displays it in a nice readable format, just the things the session says it is thinking about, the searches
it runs, the formatted tool calls it makes to code interpreter and the fital result at the end
⏺ I'll help you create an HTML page to display the transcript from full.json. Let me first check the structure of that
file to understand what we're working with.
⏺ Read(full.json)
⎿  Error: File content (36647 tokens) exceeds maximum allowed tokens (25000). Please use offset and limit parameters to
read specific portions of the file, or use the GrepTool to search for specific content.
⏺ Read(full.json)
⎿  Read 100 lines
⏺ Search(pattern: ""type": "(reasoning|web_search_call|code_interpreter_call|text)"", path: "full.json", output_mode:
"content", head_limit: 50)
⎿  Found 50 lines (ctrl+o to expand)
⏺ Bash(tail -100 /private/tmp/geo/full.json | head -50)
⎿ "url": "https://bnr.bg/ru/post/101067363/edinstvennii-orkestrion-v-bolgarii-hranitsa-v-istoricheskom
-muzee-g-gabrovo#:~:text=%D0%B8%D0%B7%D0%B3%D0%BE%D1%82%D0%BE%D0%B2%D0%BB%D0%B5%D0%BD%D0%B8%D1%8E%20%D1%82%D0%BE%D
0%BD%D0%BA%D0%B8%D1%85%20%D1%82%D0%BA%D0%B0%D0%BD%D0%B5%D0%B9%20%D0%B8%D0%B7%20%D1%88%D0%B5%D1%80%D1%81%D1%82%D0%B
… +221 lines (ctrl+o to expand)
⏺ Now I'll create a nice HTML page to display the transcript. Based on the structure, I can see it contains reasoning
summaries, web searches, and a final JSON result.
⏺ Write(viewer.html)
⎿  Wrote 417 lines to viewer.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Deep Research Session Viewer</title>
<style>
* {
margin: 0;
padding: 0;
… +407 lines (ctrl+o to expand)
⏺ I've created viewer.html that will display the deep research transcript from full.json. The page features:
Display sections:
- Header - Shows model info, status, and timestamp
- Statistics - Count of thinking steps, searches, pages visited, and total steps
- Timeline view - Chronological display of:
- 🧠 Thinking sections (reasoning summaries)
- 🔍 Search queries
- 🌐 Pages visited (with clickable links)
- Final Result - The JSON output at the end (syntax highlighted in a dark theme code block)
Design features:
- Beautiful gradient purple header
- Color-coded sections (blue for thinking, orange for searches, green for page visits)
- Responsive layout that works on mobile and desktop
- Timeline visualization with connecting line
- Professional dark code editor theme for the final JSON result
To view it, simply open viewer.html in your browser. The page will automatically load and parse full.json via fetch()
and display everything in a clean, readable format.
> Now read https://tools.simonwillison.net/json-string-extractor using curl and imitate the mechanism it uses for both
allowing users to paste in JSON or allowing them to provide a Gist URL
⏺ Bash(curl -s "https://tools.simonwillison.net/json-string-extractor")
⎿  <!DOCTYPE html>
<html lang="en">
<head>
… +440 lines (ctrl+o to expand)
⏺ Now I'll update the viewer to support both pasting JSON directly and loading from a Gist URL:
⏺ Read(viewer.html)
⎿  Read 417 lines
⏺ Write(viewer.html)
⎿  Updated viewer.html with 295 additions and 20 removals
48 padding: 30px;
49 }
50
51 + .input-section {
52 + margin-bottom: 30px;
53 + padding: 20px;
54 + background: #f8f9fa;
55 + border-radius: 8px;
56 + }
57 +
58 + .button-group {
59 + display: flex;
60 + gap: 10px;
61 + margin-bottom: 15px;
62 + flex-wrap: wrap;
63 + }
64 +
65 + button {
66 + padding: 10px 20px;
67 + border: none;
68 + border-radius: 6px;
69 + cursor: pointer;
70 + font-size: 14px;
71 + font-weight: 500;
72 + transition: all 0.2s;
73 + font-family: inherit;
74 + }
75 +
76 + button.primary {
77 + background: #667eea;
78 + color: white;
79 + }
80 +
81 + button.primary:hover {
82 + background: #5568d3;
83 + }
84 +
85 + button.secondary {
86 + background: #6c757d;
87 + color: white;
88 + }
89 +
90 + button.secondary:hover {
91 + background: #5a6268;
92 + }
93 +
94 + button:disabled {
95 + background: #ccc;
96 + cursor: not-allowed;
97 + }
98 +
99 + textarea {
100 + width: 100%;
101 + min-height: 150px;
102 + padding: 12px;
103 + border: 2px solid #ddd;
104 + border-radius: 6px;
105 + font-family: 'Monaco', 'Courier New', monospace;
106 + font-size: 14px;
107 + resize: vertical;
108 + margin-top: 10px;
109 + }
110 +
111 + textarea:focus {
112 + outline: none;
113 + border-color: #667eea;
114 + }
115 +
116 .section {
117 margin-bottom: 30px;
118 border-left: 4px solid #667eea;
...
202 color: #c62828;
203 padding: 20px;
204 border-radius: 8px;
205 - margin: 20px;
205 + margin-bottom: 20px;
206 }
207
208 .timeline {
...
238 background: #e0e0e0;
239 }
240
241 + .welcome {
242 + text-align: center;
243 + padding: 50px 20px;
244 + color: #666;
245 + }
246 +
247 + .welcome h2 {
248 + color: #667eea;
249 + margin-bottom: 15px;
250 + }
251 +
252 @media (max-width: 768px) {
253 body {
254 padding: 10px;
...
250 .stats {
251 flex-direction: column;
252 }
253 +
254 + .button-group {
255 + flex-direction: column;
256 + }
257 +
258 + button {
259 + width: 100%;
260 + }
261 }
262 </style>
263 </head>
264 <body>
265 <div class="container">
266 <header>
267 - <h1>Deep Research Session</h1>
268 - <div class="meta" id="meta-info">Loading session details...</div>
267 + <h1>Deep Research Session Viewer</h1>
268 + <div class="meta" id="meta-info">Load a transcript to view details</div>
269 </header>
270
271 <div class="content">
272 - <div class="loading" id="loading">Loading transcript...</div>
272 + <div class="input-section">
273 + <div class="button-group">
274 + <button id="loadLocal" class="primary">Load local full.json</button>
275 + <button id="loadGist" class="secondary">Load from Gist</button>
276 + <button id="togglePaste" class="secondary">Paste JSON</button>
277 + </div>
278 + <textarea id="jsonInput" placeholder="Paste your deep research transcript JSON
+ here..." style="display: none;"></textarea>
279 + </div>
280 +
281 + <div id="error" style="display: none;"></div>
282 +
283 + <div id="welcome" class="welcome">
284 + <h2>Welcome!</h2>
285 + <p>Load a deep research transcript using one of the options above.</p>
286 + </div>
287 +
288 <div id="main-content" style="display: none;">
289 <div class="stats" id="stats"></div>
290 <div class="timeline" id="timeline"></div>
291 <div class="final-result" id="final-result"></div>
292 </div>
293 - <div id="error" style="display: none;"></div>
293 </div>
294 </div>
295
296 - <script>
297 - async function loadTranscript() {
296 + <script type="module">
297 + const jsonInput = document.getElementById('jsonInput');
298 + const errorDiv = document.getElementById('error');
299 + const welcomeDiv = document.getElementById('welcome');
300 + const mainContent = document.getElementById('main-content');
301 + const loadLocalBtn = document.getElementById('loadLocal');
302 + const loadGistBtn = document.getElementById('loadGist');
303 + const togglePasteBtn = document.getElementById('togglePaste');
304 +
305 + function showError(message) {
306 + errorDiv.textContent = message;
307 + errorDiv.style.display = 'block';
308 + welcomeDiv.style.display = 'none';
309 + mainContent.style.display = 'none';
310 + }
311 +
312 + function hideError() {
313 + errorDiv.style.display = 'none';
314 + }
315 +
316 + function extractGistId(input) {
317 + // Try to extract Gist ID from URL or use input directly
318 + const urlMatch = input.match(/gist\.github\.com\/[^\/]+\/([a-fA-F0-9]+)/i);
319 + if (urlMatch) {
320 + return urlMatch[1];
321 + }
322 + // Check if it's just a Gist ID (alphanumeric string)
323 + if (/^[a-fA-F0-9]+$/i.test(input.trim())) {
324 + return input.trim();
325 + }
326 + return null;
327 + }
328 +
329 + async function loadGistJSON(gistId) {
330 + const response = await fetch(`https://api.github.com/gists/${gistId}`);
331 +
332 + if (!response.ok) {
333 + throw new Error(`Failed to fetch Gist: ${response.status} ${response.statusText}`);
334 + }
335 +
336 + const gistData = await response.json();
337 +
338 + // Find the first file that contains valid JSON
339 + for (const filename in gistData.files) {
340 + const file = gistData.files[filename];
341 + try {
342 + // Try to parse the content as JSON
343 + const parsed = JSON.parse(file.content);
344 + return parsed;
345 + } catch (e) {
346 + // Not valid JSON, continue to next file
347 + continue;
348 + }
349 + }
350 +
351 + throw new Error('No valid JSON file found in the Gist');
352 + }
353 +
354 + async function loadGist() {
355 + const input = prompt('Enter a Gist URL or Gist ID:');
356 +
357 + if (!input) {
358 + return; // User cancelled
359 + }
360 +
361 + const gistId = extractGistId(input);
362 +
363 + if (!gistId) {
364 + showError('Invalid Gist URL or ID. Please provide a valid GitHub Gist URL or ID.');
365 + return;
366 + }
367 +
368 + loadGistBtn.disabled = true;
369 + loadGistBtn.textContent = 'Loading...';
370 +
371 try {
372 + const data = await loadGistJSON(gistId);
373 + displayTranscript(data);
374 +
375 + // Update the URL hash
376 + window.location.hash = `gist=${gistId}`;
377 +
378 + loadGistBtn.textContent = 'Load from Gist';
379 + } catch (e) {
380 + showError(`Failed to load Gist: ${e.message}`);
381 + loadGistBtn.textContent = 'Load from Gist';
382 + } finally {
383 + loadGistBtn.disabled = false;
384 + }
385 + }
386 +
387 + async function loadGistFromHash() {
388 + const hash = window.location.hash;
389 + const gistMatch = hash.match(/gist=([a-fA-F0-9]+)/i);
390 +
391 + if (gistMatch) {
392 + const gistId = gistMatch[1];
393 + try {
394 + const data = await loadGistJSON(gistId);
395 + displayTranscript(data);
396 + } catch (e) {
397 + showError(`Failed to load Gist from URL: ${e.message}`);
398 + }
399 + }
400 + }
401 +
402 + async function loadLocalFile() {
403 + loadLocalBtn.disabled = true;
404 + loadLocalBtn.textContent = 'Loading...';
405 +
406 + try {
407 const response = await fetch('full.json');
408 if (!response.ok) {
409 throw new Error(`HTTP error! status: ${response.status}`);
410 }
411 const data = await response.json();
412 displayTranscript(data);
413 +
414 + // Clear hash
415 + window.location.hash = '';
416 +
417 + loadLocalBtn.textContent = 'Load local full.json';
418 } catch (error) {
419 - document.getElementById('loading').style.display = 'none';
420 - const errorDiv = document.getElementById('error');
421 - errorDiv.style.display = 'block';
422 - errorDiv.innerHTML = `
423 - <h2>Error Loading Transcript</h2>
424 - <p>${error.message}</p>
425 - <p>Make sure <code>full.json</code> is in the same directory as this HTML
- file.</p>
426 - `;
419 + showError(`Error loading local file: ${error.message}. Make sure full.json is in the
+ same directory.`);
420 + loadLocalBtn.textContent = 'Load local full.json';
421 + } finally {
422 + loadLocalBtn.disabled = false;
423 }
424 }
425
426 + function togglePasteArea() {
427 + if (jsonInput.style.display === 'none') {
428 + jsonInput.style.display = 'block';
429 + togglePasteBtn.textContent = 'Hide paste area';
430 + jsonInput.focus();
431 + } else {
432 + jsonInput.style.display = 'none';
433 + togglePasteBtn.textContent = 'Paste JSON';
434 + }
435 + }
436 +
437 + function processJSON() {
438 + const input = jsonInput.value.trim();
439 +
440 + if (!input) {
441 + return;
442 + }
443 +
444 + try {
445 + const parsed = JSON.parse(input);
446 + hideError();
447 + displayTranscript(parsed);
448 +
449 + // Clear hash
450 + window.location.hash = '';
451 + } catch (e) {
452 + showError(`Invalid JSON: ${e.message}`);
453 + }
454 + }
455 +
456 function displayTranscript(data) {
457 - document.getElementById('loading').style.display = 'none';
458 - document.getElementById('main-content').style.display = 'block';
457 + hideError();
458 + welcomeDiv.style.display = 'none';
459 + mainContent.style.display = 'block';
460
461 // Display meta info
462 const metaInfo = document.getElementById('meta-info');
...
308 let thinkingCount = 0;
309 let searchCount = 0;
310 let pageOpenCount = 0;
311 + let codeInterpreterCount = 0;
312 let finalResult = null;
313
314 data.output.forEach(item => {
...
318 if (item.action.type === 'search') searchCount++;
319 if (item.action.type === 'open_page') pageOpenCount++;
320 }
321 + if (item.type === 'code_interpreter_call') {
322 + codeInterpreterCount++;
323 + }
324 });
325
326 // Find final result
...
328
329 // Display stats
330 const statsDiv = document.getElementById('stats');
331 - statsDiv.innerHTML = `
331 + let statsHtml = `
332 <div class="stat">
333 <div class="stat-value">${thinkingCount}</div>
334 <div class="stat-label">Thinking Steps</div>
...
341 <div class="stat-value">${pageOpenCount}</div>
342 <div class="stat-label">Pages Visited</div>
343 </div>
344 + `;
345 +
346 + if (codeInterpreterCount > 0) {
347 + statsHtml += `
348 + <div class="stat">
349 + <div class="stat-value">${codeInterpreterCount}</div>
350 + <div class="stat-label">Code Executions</div>
351 + </div>
352 + `;
353 + }
354 +
355 + statsHtml += `
356 <div class="stat">
357 <div class="stat-value">${data.output.length}</div>
358 <div class="stat-label">Total Steps</div>
359 </div>
360 `;
361
362 + statsDiv.innerHTML = statsHtml;
363 +
364 // Display timeline
365 const timeline = document.getElementById('timeline');
366 let timelineHtml = '';
...
387 `;
388 }
389 }
390 +
391 + if (item.type === 'code_interpreter_call' && item.action) {
392 + const code = item.action.code || '';
393 + timelineHtml += `
394 + <div class="timeline-item">
395 + <div class="thinking" style="background: #fff9e6; border-left-color:
+ #ffc107;">
396 + <h3 style="color: #ffc107;">Code Execution</h3>
397 + <pre style="background: #1e1e1e; color: #d4d4d4; padding: 10px;
+ border-radius: 4px; overflow-x: auto; font-size: 0.85em;">${escapeHtml(code)}</pre>
398 + </div>
399 + </div>
400 + `;
401 + }
402 });
403
404 timeline.innerHTML = timelineHtml;
...
394 // Display final result
395 const finalResultDiv = document.getElementById('final-result');
396 if (finalResult) {
397 + finalResultDiv.style.display = 'block';
398 finalResultDiv.innerHTML = `
399 <h2>Final Result</h2>
400 <pre>${escapeHtml(finalResult)}</pre>
...
409 return div.innerHTML;
410 }
411
412 - // Load transcript on page load
413 - loadTranscript();
412 + // Event listeners
413 + loadLocalBtn.addEventListener('click', loadLocalFile);
414 + loadGistBtn.addEventListener('click', loadGist);
415 + togglePasteBtn.addEventListener('click', togglePasteArea);
416 + jsonInput.addEventListener('input', processJSON);
417 +
418 + // Load Gist from hash if present on page load
419 + loadGistFromHash();
420 </script>
421 </body>
422 </html>
⏺ Perfect! I've updated viewer.html to support multiple loading methods, just like Simon Willison's tool. Here's what's
new:
New Features
Three ways to load transcripts:
1. Load local full.json - Fetches the local file via fetch() (original behavior)
2. Load from Gist - Prompts for a GitHub Gist URL or ID
- Extracts Gist ID from full URLs like https://gist.github.com/username/abc123
- Accepts just the ID like abc123
- Uses GitHub API to fetch the first valid JSON file
- Updates URL hash with #gist=<id> for sharing
3. Paste JSON - Toggles a textarea where you can paste JSON directly
- Parses as you type
- Clears the hash when using pasted content
Key Implementation Details (from Simon's tool)
- Gist ID extraction: Regex pattern matches GitHub Gist URLs or validates raw IDs
- Hash-based loading: On page load, checks for #gist=<id> in URL and auto-loads
- GitHub API integration: Fetches from https://api.github.com/gists/{id} and finds first valid JSON file
- Live parsing: Input event listener on textarea for real-time processing
Additional Improvements
- Added support for code interpreter calls (displayed in yellow with code syntax)
- Welcome screen when no transcript is loaded
- Better error handling with descriptive messages
- Mobile-responsive button layout
Now you can share deep research transcripts via Gist URL, paste them directly, or load local files!
> change the "Load local full.json" button to be "load example" instead, it should load
https://gist.github.com/simonw/3454a4ce40f8547a5c65c911de611ff4
⏺ Update(viewer.html)
⎿  Updated viewer.html with 1 addition and 1 removal
347 <div class="content">
348 <div class="input-section">
349 <div class="button-group">
350 - <button id="loadLocal" class="primary">Load local full.json</button>
350 + <button id="loadExample" class="primary">Load example</button>
351 <button id="loadGist" class="secondary">Load from Gist</button>
352 <button id="togglePaste" class="secondary">Paste JSON</button>
353 </div>
⏺ Update(viewer.html)
⎿  Updated viewer.html with 1 addition and 1 removal
374 const errorDiv = document.getElementById('error');
375 const welcomeDiv = document.getElementById('welcome');
376 const mainContent = document.getElementById('main-content');
377 - const loadLocalBtn = document.getElementById('loadLocal');
377 + const loadExampleBtn = document.getElementById('loadExample');
378 const loadGistBtn = document.getElementById('loadGist');
379 const togglePasteBtn = document.getElementById('togglePaste');
380
⏺ Update(viewer.html)
⎿  Updated viewer.html with 11 additions and 14 removals
475 }
476 }
477
478 - async function loadLocalFile() {
479 - loadLocalBtn.disabled = true;
480 - loadLocalBtn.textContent = 'Loading...';
478 + async function loadExample() {
479 + loadExampleBtn.disabled = true;
480 + loadExampleBtn.textContent = 'Loading...';
481
482 try {
483 - const response = await fetch('full.json');
484 - if (!response.ok) {
485 - throw new Error(`HTTP error! status: ${response.status}`);
486 - }
487 - const data = await response.json();
483 + const gistId = '3454a4ce40f8547a5c65c911de611ff4';
484 + const data = await loadGistJSON(gistId);
485 displayTranscript(data);
486
487 - // Clear hash
488 - window.location.hash = '';
487 + // Update the URL hash
488 + window.location.hash = `gist=${gistId}`;
489
490 - loadLocalBtn.textContent = 'Load local full.json';
490 + loadExampleBtn.textContent = 'Load example';
491 } catch (error) {
492 - showError(`Error loading local file: ${error.message}
- . Make sure full.json is in the same directory.`);
493 - loadLocalBtn.textContent = 'Load local full.json';
492 + showError(`Error loading example: ${error.message}`);
493 + loadExampleBtn.textContent = 'Load example';
494 } finally {
495 - loadLocalBtn.disabled = false;
495 + loadExampleBtn.disabled = false;
496 }
497 }
498
⏺ Update(viewer.html)
⎿  Updated viewer.html with 1 addition and 1 removal
676 }
677
678 // Event listeners
679 - loadLocalBtn.addEventListener('click', loadLocalFile);
679 + loadExampleBtn.addEventListener('click', loadExample);
680 loadGistBtn.addEventListener('click', loadGist);
681 togglePasteBtn.addEventListener('click', togglePasteArea);
682 jsonInput.addEventListener('input', processJSON);
⏺ Done! I've updated the viewer so the "Load local full.json" button is now "Load example" and it loads the gist at
https://gist.github.com/simonw/3454a4ce40f8547a5c65c911de611ff4.
The button now:
- Shows "Load example" text
- Loads the specified gist when clicked
- Updates the URL hash to #gist=3454a4ce40f8547a5c65c911de611ff4 so the example can be shared via link
- Shows loading state while fetching
Users can still load their own transcripts using the "Load from Gist" or "Paste JSON" options.
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
⏵⏵ bypass permissions on (shift+tab to cycle)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment