From daymade-claude-code
Reads or exports the complete transcript and files from a Claude.ai web conversation (private or shared links) via the internal API through the user's logged-in Chrome.
How this skill is triggered — by the user, by Claude, or both
Slash command
/daymade-claude-code:read-claude-web-conversationThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Pull a Claude.ai **web** conversation into a full, structured transcript — every
Pull a Claude.ai web conversation into a full, structured transcript — every message and every tool call, not just what is currently on screen.
Verified against Claude.ai's web API as of July 2026. These are the same private JSON endpoints the Claude.ai front-end calls; they are not a documented or version-stable public API, so if a request 404s, re-derive the shape from the Network tab (see references/claude-web-api-extraction.md).
Pick by the source you are holding, not by the word "conversation":
| Source | Use |
|---|---|
A live claude.ai/chat/… or claude.ai/share/… URL | This skill |
Local Claude Code sessions (~/.claude/projects/*.jsonl) | claude-code-history-files-finder |
An already-exported .txt / .json conversation file | claude-export-txt-better |
Four traps, and every one of them fails silently — you get back something that looks like a complete export, and you only notice the loss if you happen to know how long the conversation really was. Assume you are being lied to by default; the fidelity gate in Step 4 exists to make the lies audible.
curl and WebFetch don't get an auth redirect — they get a
Cloudflare challenge page (HTTP 403, Just a moment...). Nothing you do to
the headers fixes this; the page is gated on a session that lives in the
user's Chrome. Never reach for curl here, not even to "just check".get_page_text and DOM
scraping still only see the handful of messages currently rendered — often
just the last one. A 40-message thread comes back as 1.render_all_tools=true — 5%. Always request the full rendering.knowledge items, not text items. A renderer
that only knows text returns the empty string for them and reports no error —
the same real conversation rendered to 8k chars instead of 146k (4.8%
retention) and looked completely fine.The reliable path: run JavaScript inside the user's logged-in page and let
fetch inherit the session cookie, then render locally through the fidelity gate.
Three channels can execute JS inside the user's page. They differ only in plumbing, and each fails in its own way, so pick in this order and verify rather than assume.
| Order | Channel | Use when | Fails when |
|---|---|---|---|
| 1 | claude-in-chrome extension | list_connected_browsers returns a browser | Returns [] → the extension's claude.ai login ≠ the Claude Code account. This is structural — retrying, reinstalling, and switch_browser all fail. Move on immediately. |
| 2 | CDP (scripts/cdp_channel.py probe) | probe says available: true — then it is the best channel there is | Usually unavailable, and that is normal, not a malfunction — see below. Probe is cheap; believe its answer and move on. |
| 3 | AppleScript (macOS only) | The realistic fallback when the extension can't pair | See the routing trap below — check for it BEFORE blaming the user |
| ✗ | curl / WebFetch | never | Cloudflare 403. There is no header that fixes it. |
⚠️ Expect CDP to be unavailable, and never try to force it. Since Chrome 136
(April 2025) the debugging port is ignored on the default user-data-dir — a
deliberate hardening against malware that used CDP to steal cookies. You may still
find a listening socket and a stale DevToolsActivePort on disk while the
WebSocket handshake is never answered and /json/* returns 404.
And its availability flaps. Verified on Chrome 150, one machine, one browser session: the endpoint worked, then stopped answering entirely, then answered again — with no restart in between. So probe every time and never cache the verdict. "CDP worked five minutes ago" is not evidence that it works now, and "CDP failed once" is not evidence that this machine can't use it. The probe is cheap precisely so you can afford to re-ask.
The trap is what you'll be tempted to do next. Every fix on the web says "relaunch
Chrome with --user-data-dir=/tmp/whatever". For this skill that is worse than
useless: a fresh profile is signed out, and a signed-out browser cannot read a
single one of the user's conversations. The session you need lives in precisely
the profile Chrome is refusing to expose. So probe, take the answer, and fall
back — never reconfigure or relaunch the user's browser to chase a port.
CDP does work when Chrome was deliberately started on a non-default
--user-data-dir and is signed into claude.ai. That happens, and when it does
this channel beats the others outright (no menu toggle, immune to the Apple Events
capture below, enumerates every tab, returns the whole payload on stdout). That is
why it is worth one cheap probe before falling through.
To try channel 1, load the extension's tools in a single ToolSearch call
(they are deferred; the API path needs no get_page_text / read_page):
ToolSearch: select:mcp__claude-in-chrome__tabs_context_mcp,mcp__claude-in-chrome__navigate,mcp__claude-in-chrome__javascript_tool
then call list_connected_browsers. An empty list is the account mismatch — go to
channel 2 rather than retrying.
To try channel 2:
uv run --with websockets python scripts/cdp_channel.py probe
probe answers three questions at once: is CDP available, is it pointed at the
user's real browser (page count, and any claude.ai tabs already open), and are
there multiple Chrome instances running. Its output drives everything below —
including the AppleScript decision, since the chrome_instances.automation list is
what tells you whether Apple Events will even reach the right browser.
Exit codes are a contract, so you never have to guess whether to fall back:
| code | meaning | what to do |
|---|---|---|
| 0 | worked | continue |
| 1 | bad input (unreadable --js, etc.) | fix the call |
| 2 | TAB_NOT_FOUND | use open, or ask the user to open the page |
| 3 | CDP unusable | fall back to another channel — this is the normal outcome |
| 4 | the browser answered with an error | the command was wrong, or the tab vanished mid-call; falling back won't help |
Exit 3 still prints chrome_instances, because that part needs no debugging port.
It is a routing fact, not an error: read it, fall through to channel 3, and say
nothing to the user about debugging ports.
AppleScript addresses Chrome by bundle id. If a second Chrome instance is
running — chrome-devtools-mcp, puppeteer, playwright, anything launched with its
own --user-data-dir — Apple Events can land on that instance instead, and
macOS gives you no way to target the first one by pid. There is no workaround.
What makes this genuinely dangerous is how it presents: the automation profile
has "Allow JavaScript from Apple Events" switched off, so your JS attempt fails
with "Executing JavaScript through AppleScript is turned off" — and you will
conclude the user forgot a menu toggle, and go ask them to flip it, while their
real browser had it enabled the whole time. Observed exactly this: AppleScript
reported windows=1 tabs=1 while the user's actual Chrome had 35 tabs open.
So before falling back to AppleScript, run probe (or chrome_instances() from
cdp_channel.py) and read the automation list. If it is non-empty, say so
plainly — "an automation Chrome is holding the Apple Events route, so
AppleScript will hit the wrong browser" — and either use CDP, or ask whether
that instance can be closed. Do not relay a toggle error you cannot attribute.
Channel details and the base64 binary bridge: references/applescript_fallback_channel.md.
How much of the conversation exists for you to fetch depends on whose account the browser is signed into. Establish this early — it is the difference between a complete archive and one with unrecoverable holes, and the user deserves to know which they are getting before you hand it over.
| Case | Signal | What you can get |
|---|---|---|
| A · Not signed in | /api/organizations returns no org | Nothing for /chat/ links. Stop and ask the user to sign into claude.ai in Chrome — never automate a login. |
| B · Signed in, conversation belongs to this account | GET .../chat_conversations/<conversation-id> returns 200 | Everything, including the content of uploaded attachments. Prefer this path whenever it is available. |
| C · Signed in, but it's someone else's share link | the conversation fetch 404s; the snapshot's creator ≠ this account | The snapshot only. The platform strips the arguments and results of every tool call that touched the sharer's uploads. Unrecoverable from that link. |
For a /share/… link, the snapshot payload itself tells you which case you're
in: it carries conversation_uuid (the original) and creator. So fetch the
snapshot first, then try the original with that conversation_uuid — if it
200s you are in case B and should re-export from there, because the snapshot is
strictly lossier. If it 404s, you are in case C: say so out loud, and let the
render step disclose the gaps in the transcript header (it does this on its own).
Case C's holes are real and worth naming precisely, because they look like a bug
in your export when they are not: a view of an uploaded file keeps its name but
loses its path and the file's entire content. The user's own copy of that file
is usually sitting on their disk — the export isn't broken, it just cannot speak
for a file the platform declined to share.
Extension: tabs_context_mcp with { "createIfEmpty": true }, then navigate.
CDP: cdp_channel.py open --url <url> (reuses an existing tab; waits out the
Cloudflare interstitial, which the browser clears by itself).
Either way, confirm it actually loaded and is logged in — the tab title should be the conversation's name, not "Log in". If you land on a login page, stop and tell the user; do not automate a login.
Always request the full tool rendering. Without render_all_tools=true you
get placeholders where the analysis was (trap 3 above).
| Link type | Endpoint |
|---|---|
/chat/<conversation-id> | GET /api/organizations/<org-uuid>/chat_conversations/<conversation-id>?tree=True&rendering_mode=messages&render_all_tools=true |
/share/<snapshot-id> | GET /api/chat_snapshots/<snapshot-id>?rendering_mode=messages&render_all_tools=true |
Note the share endpoint keys on the snapshot id from the URL, which is not
the conversation id — feeding a snapshot id to chat_conversations 404s, and that
404 means "wrong id", not "you lack access". Both id families are opaque; always
derive them from the open URL rather than hard-coding.
window.__claudeExport.rawJson in ~16k .slice() windows.uv run --with websockets python scripts/cdp_channel.py eval \
--match <conversation-or-snapshot-id> --js fetch.js --out conversation.json
awaitPromise resolves the async fetch in one call, and the value comes back on
stdout, so a multi-megabyte payload never has to cross a context window.scripts/runjs.applescript, redirecting
stdout to the file (see the channel reference).For a plain chat with no tool calls, where you just need the text right now, the inline snippet in references/claude-web-api-extraction.md assembles a transcript in-page in a single call.
uv run python scripts/render_transcript.py conversation.json -o transcript.md \
--source-url <url>
This handles both payload shapes (/chat/ and /share/), renders every tool
call, folds tool outputs into <details>, and collects web_search citations into
a "Sources cited" list.
It also refuses to write a lossy transcript. Before emitting anything it audits every block — this block carried N characters; did the renderer emit anything at all for it? — and exits 2 if any block carried text and produced nothing. That is the trap-4 failure, and it is invisible without this check: the markdown looks clean, the exit code is 0, and 95% of the conversation is gone. The budget is measured off the raw payload, deliberately not through the rendering path, because a gate that asks the parser how much there was to render can only ever confirm the parser's own blind spots.
If it fails, it names the offending block and the command to inspect it: teach
result_item_text()/render_block() the new shape. Reach for --allow-lossy
only when you have consciously decided the loss is acceptable — it is almost
never the right answer, and it is never the right first answer.
Blocks the platform emptied (case C) are counted separately as a disclosed gap, never as loss, and are announced in the transcript header. A clean run prints its own accounting:
fidelity: 143,088/143,088 chars rendered (100.0%)
known gap: 12 tool blocks were emptied by the platform (view) — disclosed in the
transcript header, NOT recoverable from a shared link
The gate audits per item, not per block — items are the granularity content
actually gets lost at, and a per-block check credited a whole block as rendered
whenever any part of it came out, so a vanished 38k-char item hid behind a
16-char sibling and scored 100%. It also covers what a content[]-only audit
structurally cannot see: message-level attachment bodies (a pasted document
lives there, not in a block), the top-level text, and messages the active-path
walk never reached. And a payload with nothing in it scores 0%, not 100% — an
empty fetch is the limit case of the very loss this gate exists for.
If you change the renderer, run the regression suite. Every case in it is a shape that fooled a previous version of the gate:
uv run python scripts/selftest_fidelity.py
Other modes, unchanged: --list-files inventories every downloadable file with
the endpoint family each needs; --extract-file <sandbox-path> reconstructs a
sandbox-created file by replaying its create_file plus every later
str_replace, and refuses to emit anything if a replacement is missing or
ambiguous rather than handing back stale content.
javascript_tool truncates large return values. Fetch the chars count first,
then re-run returning later windows — keep the fetch identical and change only
the final expression:
transcript.slice(14000, 32000); // then (32000, 50000) … until you've covered `chars`
Prefer ~14–18k windows; larger risks hitting the limit again. The CDP and AppleScript channels don't need any of this — both return the whole payload on stdout, which is a good reason to prefer them for a large archival export.
sender values are 'human' and 'assistant' (not 'user'/'claude').m.text AND m.content[] at the same time. Agent turns
often carry the final answer in m.text plus thinking/tool_use/tool_result
blocks in content[]. Build from content[] first and fold in m.text — never
m.text || (content…), which short-circuits and drops every block whenever
m.text is set. If a message renders blank, inspect one raw:
Object.keys(msgs[0]) and msgs[0].content?.map(b => b.type)./share/ payload is shaped differently from /chat/. name is null (the
title is in snapshot_name); web_search results are knowledge items
(title/url/text), not text items; and citations hang off the text
blocks. The bundled renderer handles all three — a hand-rolled one usually
doesn't, which is trap 4.input: {} / content: [] on a tool block is not a bug — on a share
snapshot it is the platform withholding the sharer's private file contents. Say
so; don't render it as a mysterious no-op, and don't count it as data loss.rendering_mode=raw returns empty or short bodies, retry with
rendering_mode=messages. The two expose slightly different fields.conversations/<id>/wiggle/download-file).
uuid-guessing 404s for uploads, which looks like — but is not — "unsupported".
scripts/download_files.js inventories the
conversation and pulls each through the right endpoint. Don't conclude anything
is unavailable before trying it.tree=True returns the whole tree, including branches abandoned by edits and
regenerations. Walk the active path from current_leaf_message_uuid via
parent_message_uuid so dead branches don't leak into the transcript or inflate
the message count. The renderer does this and falls back to array order when
those fields are absent (correct for single-chain conversations, and for share
snapshots, which are already linear).orgs[0] may be the wrong one. If a conversation
404s, list them — orgs.map(o => ({uuid: o.uuid, name: o.name})) — and loop the
fetch across orgs before concluding it's inaccessible.Full endpoint table, response schema field-by-field, the complete export script, and a troubleshooting table: references/claude-web-api-extraction.md.
Once you have the transcript, suggest the natural follow-up — opt-in, never automatic:
Got the full conversation (<N> messages, "<title>", <retention>% fidelity).
Options:
A) Clean it up — run transcript-fixer if it's ASR/garbled (only if relevant)
B) Summarize / extract the decisions and action items
C) Save it to a file — tell me where
D) Also download the conversation's files (uploads / deliverables / charts)
E) Nothing else — you just needed it read
If the transcript showed file activity (uploads listed, Download cards,
present_files blocks), mention option D explicitly — users routinely assume the
files are lost when only the text is exported. And if the render reported a known
gap (case C), lead with that instead: the user needs to know their archive has
holes the link can never fill, while they still have the originals to hand.
npx claudepluginhub p/daymade-daymade-claude-code-daymade-claude-codeParses Claude Code JSONL conversation transcripts to extract signal from user/assistant messages, filter noise entries, link subagent files, detect session boundaries, and understand storage format.
Gives Claude Code full internet access with three-layer channel dispatch (WebSearch/WebFetch/CDP), Chrome DevTools Protocol browser automation via a local proxy, and parallel sub-agent task splitting.
Extracts conversation transcripts from Clawdbot, Claude Code, and Codex .jsonl session files. Used to export prompt history or session logs.