cd /news/ai-tools/claude-project-exporter-markdown-att… · home › topics › ai-tools › article
[ARTICLE · art-145096] src=gist.github.com ↗ pub= topic=ai-tools verified=true sentiment=· neutral

Claude project exporter – markdown + attachments + outputs

A developer published a browser-based exporter that saves Claude projects as Markdown files along with their attachments and generated outputs. The tool runs client-side against Claude's internal API, using the File System Access API to write conversation content, attachments, and outputs into a structured local directory with sanitized, de-duplicated filenames.

by read35 min views4 publishedOct 5, 2026

| | (async () => { | | | 'use strict'; |

|  | const API = '/api'; | 
|  | const sleep = ms => new Promise(r => setTimeout(r, ms)); | 
|  | // ============================================================ | 

| | // Helpers |

|  | // ============================================================ | 
|  | function asArray(v) { | 
|  | if (Array.isArray(v)) return v; | 
|  | if (v && Array.isArray(v.data)) return v.data; | 
|  | if (v && Array.isArray(v.items)) return v.items; | 
|  | if (v && Array.isArray(v.results)) return v.results; | 
|  | return []; | 

| | } |

|  | async function fetchJSON(url, optional = false) { | 
|  | const r = await fetch(url, { | 

| | credentials: 'include', |

|  | headers: { Accept: 'application/json' }, | 
|  | }); | 
|  | if (!r.ok) { | 
|  | if (optional && (r.status === 403 \|\| r.status === 404)) return null; | 
|  | const body = await r.text().catch(() => ''); | 

| | throw new Error( |

|  | `${r.status} ${r.statusText}: ${url}` + | 
|  | (body ? `\n${body.slice(0, 500)}` : '') | 
|  | ); | 

| | } | | | return r.json(); | | | } |

|  | async function fetchBlob(url, optional = false) { | 
|  | const r = await fetch(url, { credentials: 'include' }); | 
|  | if (!r.ok) { | 
|  | if (optional && (r.status === 403 \|\| r.status === 404)) return null; | 
|  | throw new Error(`${r.status} ${r.statusText}: ${url}`); | 

| | } | | | return r.blob(); | | | } |

|  | function absUrl(url) { | 
|  | if (!url) return null; | 

| | try { |

|  | return new URL(url, location.origin).href; | 
|  | } catch { | 

| | return url; | | | } | | | } |

|  | function safeName(value, fallback = 'Untitled', max = 180) { | 
|  | let s = String(value \|\| fallback) | 
|  | .normalize('NFC') | 
|  | .replace(/[\u0000-\u001f\u007f]/g, '') | 
|  | .replace(/[\/\\:*?"<>\|]/g, '–') | 
|  | .trim(); | 
|  | if (!s) s = fallback; | 
|  | if (s.length > max) s = s.slice(0, max).trim(); | 

| | return s; | | | } |

|  | function makeUniqueNamer() { | 
|  | const used = new Map(); | 
|  | return name => { | 
|  | const clean = safeName(name); | 
|  | const key = clean.toLocaleLowerCase(); | 
|  | if (!used.has(key)) { | 
|  | used.set(key, 1); | 

| | return clean; | | | } |

|  | const n = used.get(key) + 1; | 
|  | used.set(key, n); | 
|  | const dot = clean.lastIndexOf('.'); | 

| | return dot > 0 |

|  | ? `${clean.slice(0, dot)} (${n})${clean.slice(dot)}` | 
|  | : `${clean} (${n})`; | 
|  | }; | 

| | } |

|  | async function getDir(parent, name) { | 
|  | return parent.getDirectoryHandle(safeName(name), { create: true }); | 

| | } |

|  | async function writeBlob(dir, name, blob) { | 
|  | const handle = await dir.getFileHandle(safeName(name), { create: true }); | 
|  | const writer = await handle.createWritable(); | 
|  | await writer.write(blob); | 
|  | await writer.close(); | 

| | } | | | async function writeText(dir, name, text) { | | | await writeBlob( | | | dir, | | | name, |

|  | new Blob([text ?? ''], { type: 'text/plain;charset=utf-8' }) | 
|  | ); | 

| | } |

|  | function iso(value) { | 
|  | if (!value) return ''; | 
|  | const d = new Date(value); | 
|  | return Number.isNaN(d.valueOf()) ? String(value) : d.toISOString(); | 

| | } |

|  | function firstString(...values) { | 
|  | for (const v of values) { | 
|  | if (typeof v === 'string' && v.trim()) return v; | 

| | } | | | return ''; | | | } |

|  | function memoryText(memory) { | 
|  | if (!memory) return ''; | 
|  | if (typeof memory === 'string') return memory; | 

| | return firstString( | | | memory.memory, | | | memory.content, | | | memory.text, | | | memory.value, | | | memory.summary | | | ); | | | } |

|  | function longestBacktickRun(text) { | 
|  | return (String(text \|\| '').match(/`+/g) \|\| []) | 
|  | .reduce((m, run) => Math.max(m, run.length), 0); | 

| | } |

|  | function fencedText(text) { | 
|  | const fence = '`'.repeat(Math.max(3, longestBacktickRun(text) + 1)); | 
|  | return `${fence}text\n${text}\n${fence}`; | 

| | } |

|  | function extensionFromMime(type) { | 
|  | const t = String(type \|\| '').toLowerCase().split(';')[0].trim(); | 
|  | const map = { | 

| | 'image/png': '.png', | | | 'image/jpeg': '.jpg', | | | 'image/webp': '.webp', | | | 'image/gif': '.gif', | | | 'image/svg+xml': '.svg', | | | 'application/pdf': '.pdf', | | | 'application/json': '.json', | | | 'text/plain': '.txt', | | | 'text/markdown': '.md', | | | 'text/html': '.html', | | | 'text/csv': '.csv', |

|  | }; | 
|  | return map[t] \|\| ''; | 

| | } |

|  | function inferArtifactExtension(input, content) { | 
|  | const kind = String( | 

| | input?.type || | | | input?.language || | | | input?.mime_type || | | | '' |

|  | ).toLowerCase(); | 
|  | const map = { | 

| | 'text/markdown': '.md', | | | markdown: '.md', | | | md: '.md', | | | 'text/plain': '.txt', | | | text: '.txt', | | | txt: '.txt', | | | 'text/html': '.html', | | | html: '.html', | | | 'image/svg+xml': '.svg', | | | svg: '.svg', | | | 'application/json': '.json', | | | json: '.json', | | | javascript: '.js', | | | js: '.js', | | | typescript: '.ts', | | | ts: '.ts', | | | jsx: '.jsx', | | | tsx: '.tsx', | | | python: '.py', | | | py: '.py', | | | css: '.css', | | | csv: '.csv', | | | xml: '.xml', | | | sql: '.sql', | | | bash: '.sh', | | | shell: '.sh', |

|  | }; | 
|  | if (map[kind]) return map[kind]; | 
|  | const s = String(content \|\| '').trimStart().toLowerCase(); | 
|  | if (s.startsWith('<svg')) return '.svg'; | 
|  | if (s.startsWith('<!doctype html') \|\| s.startsWith('<html')) return '.html'; | 

| | return '.md'; | | | } | | | // ============================================================ | | | // Project discovery |

|  | // ============================================================ | 
|  | async function discoverProjects() { | 
|  | const orgs = asArray(await fetchJSON(`${API}/organizations`)); | 
|  | const found = []; | 
|  | for (const org of orgs) { | 
|  | if (!org?.uuid) continue; | 

| | try { |

|  | const data = await fetchJSON( | 
|  | `${API}/organizations/${org.uuid}/projects` + | 

| | ?include_harmony_projects=true&limit=100 |

|  | ); | 
|  | for (const project of asArray(data)) { | 
|  | if (project?.uuid) found.push({ org, project }); | 

| | } | | | } catch (e) { | | | console.warn('Could not list projects for org', org.uuid, e); | | | } | | | } | | | return found; | | | } |

|  | function projectIdFromUrl() { | 
|  | return location.pathname.match(/\/projects?\/([0-9a-f-]{20,})/i)?.[1] \|\| null; | 

| | } |

|  | function chooseProject(projects) { | 
|  | if (!projects.length) throw new Error('No Claude projects found.'); | 
|  | const current = projectIdFromUrl(); | 
|  | if (current) { | 
|  | const match = projects.find(x => x.project.uuid === current); | 
|  | if (match) return match; | 

| | } | | | if (projects.length === 1) return projects[0]; | | | const list = projects |

|  | .map((x, i) => `${i + 1}. ${x.project.name \|\| '(unnamed project)'}`) | 
|  | .join('\n'); | 
|  | const answer = prompt( | 

| | Which project should be exported?\n\n${list}\n\nEnter a number:, | | | '1' |

|  | ); | 
|  | if (answer == null) return null; | 
|  | const idx = Number(answer) - 1; | 
|  | if (!Number.isInteger(idx) \|\| idx < 0 \|\| idx >= projects.length) { | 
|  | throw new Error('Invalid project number.'); | 

| | } | | | return projects[idx]; | | | } | | | // ============================================================ | | | // Conversation tree |

|  | // ============================================================ | 
|  | function activeBranch(messages, leafUuid) { | 
|  | if (!leafUuid \|\| !Array.isArray(messages)) return messages \|\| []; | 
|  | const byId = new Map( | 

| | messages |

|  | .filter(m => m?.uuid) | 
|  | .map(m => [m.uuid, m]) | 
|  | ); | 
|  | const branch = []; | 
|  | const seen = new Set(); | 
|  | let id = leafUuid; | 
|  | while (id && byId.has(id) && !seen.has(id)) { | 
|  | seen.add(id); | 
|  | const m = byId.get(id); | 
|  | branch.push(m); | 
|  | id = m.parent_message_uuid; | 

| | } | | | return branch.length ? branch.reverse() : messages; | | | } |

|  | function extractedTextItems(message) { | 
|  | const out = []; | 
|  | for (const obj of [ | 

| | ...asArray(message?.attachments), |

|  | ...asArray(message?.files), | 
|  | ]) { | 

| | if ( | | | typeof obj?.extracted_content === 'string' && | | | obj.extracted_content.length |

|  | ) { | 
|  | out.push({ | 

| | name: obj.file_name || obj.name || 'Attached text', | | | text: obj.extracted_content, | | | }); | | | } | | | } | | | return out; | | | } | | | // ============================================================ | | | // Citations / sources |

|  | // ============================================================ | 
|  | function citationLinesFromCitation(c) { | 
|  | const lines = []; | 
|  | if (!c \|\| typeof c !== 'object') return lines; | 
|  | if (c.url) { | 
|  | const title = firstString(c.title, c.name, c.url); | 
|  | const via = c.origin_tool_name ? ` — via ${c.origin_tool_name}` : ''; | 
|  | lines.push(`- [${title}](${c.url})${via}`); | 

| | } |

|  | for (const src of asArray(c.sources)) { | 
|  | if (typeof src === 'string') { | 
|  | lines.push(`- ${src}`); | 
|  | } else if (src && typeof src === 'object') { | 
|  | if (src.url) { | 
|  | const title = firstString(src.title, src.name, src.url); | 
|  | lines.push(`- [${title}](${src.url})`); | 

| | } | | | } | | | } |

|  | if (!c.url) { | 
|  | const docTitle = firstString( | 

| | c.document_title, | | | c.title, | | | c.file_name, | | | c.document_id, | | | c.file_id |

|  | ); | 
|  | if (docTitle) { | 
|  | let loc = ''; | 
|  | if (Number.isFinite(c.page_number)) { | 
|  | loc = ` — page ${c.page_number}`; | 
|  | } else if ( | 

| | Number.isFinite(c.start_page_number) || | | | Number.isFinite(c.end_page_number) | | | ) { | | | const a = c.start_page_number ?? c.end_page_number; | | | const b = c.end_page_number ?? c.start_page_number; |

|  | loc = a === b ? ` — page ${a}` : ` — pages ${a}–${b}`; | 
|  | } else if ( | 

| | Number.isFinite(c.start_index) || |

|  | Number.isFinite(c.end_index) | 
|  | ) { | 
|  | const a = c.start_index ?? '?'; | 
|  | const b = c.end_index ?? '?'; | 
|  | loc = ` — characters ${a}–${b}`; | 
|  | } else if ( | 

| | Number.isFinite(c.start_char_index) || | | | Number.isFinite(c.end_char_index) | | | ) { | | | const a = c.start_char_index ?? '?'; | | | const b = c.end_char_index ?? '?'; | | | loc = — characters ${a}–${b}; | | | } | | | lines.push(- ${docTitle}${loc}); | | | } | | | } | | | return lines; | | | } |

|  | function collectMessageSourceLines(message) { | 
|  | const lines = []; | 
|  | for (const b of asArray(message?.content)) { | 
|  | if (b?.type !== 'text') continue; | 
|  | for (const c of asArray(b.citations)) { | 
|  | lines.push(...citationLinesFromCitation(c)); | 

| | } | | | } | | | return [...new Set(lines)]; | | | } | | | // ============================================================ | | | // Markdown chat | | | // ============================================================ | | | function renderChatMarkdown(conversation, messages, projectName) { | | | const title = | | | conversation.name || | | | conversation.title || | | | 'Untitled chat'; | | | const created = | | | conversation.created_at || | | | messages[0]?.created_at || | | | ''; |

|  | const md = [ | 
|  | `# ${title}`, | 

| | '', | | | Project: ${projectName}, | | | '', |

|  | ]; | 
|  | if (created) md.push(`Created: ${iso(created)}`, ''); | 
|  | for (const m of messages) { | 
|  | const who = m.sender === 'human' ? 'User' : 'Claude'; | 
|  | const ts = iso(m.created_at); | 
|  | md.push(`## ${who}${ts ? ` — ${ts}` : ''}`, ''); | 
|  | const blocks = Array.isArray(m.content) ? m.content : []; | 
|  | let wroteText = false; | 
|  | for (const b of blocks) { | 
|  | if (!b \|\| typeof b !== 'object') continue; | 

| | if ( |

|  | b.type === 'text' && | 
|  | typeof b.text === 'string' && | 

| | b.text.length |

|  | ) { | 
|  | md.push(b.text, ''); | 
|  | wroteText = true; | 

| | } | | | } | | | if ( | | | !wroteText && | | | typeof m.text === 'string' && | | | m.text.length |

|  | ) { | 
|  | md.push(m.text, ''); | 

| | } |

|  | const extracted = extractedTextItems(m); | 
|  | const extractedNames = new Set(extracted.map(x => x.name)); | 
|  | for (const item of extracted) { | 

| | md.push( | | | ### Attached text — ${item.name}, | | | '', | | | fencedText(item.text), | | | '' | | | ); | | | } |

|  | const names = new Set(); | 
|  | for (const a of asArray(m.attachments)) { | 
|  | if (a?.file_name) names.add(a.file_name); | 

| | } |

|  | for (const f of asArray(m.files)) { | 
|  | if (f?.file_name) names.add(f.file_name); | 

| | } |

|  | for (const name of names) { | 
|  | if (!extractedNames.has(name)) { | 
|  | md.push(`> Attachment: ${name}`, ''); | 

| | } | | | } |

|  | if (m.sender !== 'human') { | 
|  | const sources = collectMessageSourceLines(m); | 
|  | if (sources.length) { | 

| | md.push( | | | '### Sources', | | | '', | | | ...sources, | | | '' | | | ); | | | } | | | } | | | if (m.compaction_summary) { | | | const summary = | | | typeof m.compaction_summary === 'string' | | | ? m.compaction_summary | | | : firstString( | | | m.compaction_summary.summary, | | | m.compaction_summary.text, | | | m.compaction_summary.content |

|  | ); | 
|  | if (summary) { | 

| | md.push( | | | '### Claude compaction summary', | | | '', | | | summary, | | | '' | | | ); | | | } | | | } | | | } | | | return md |

|  | .join('\n') | 
|  | .replace(/\n{4,}/g, '\n\n\n') | 
|  | .trim() + '\n'; | 

| | } | | | // ============================================================ | | | // Reconstruct final Claude artifacts |

|  | // ============================================================ | 
|  | function replaceExactlyOnce(text, oldStr, newStr) { | 
|  | const first = text.indexOf(oldStr); | 
|  | if (first < 0) return { ok: false, text, reason: 'old_str not found' }; | 
|  | const second = text.indexOf(oldStr, first + oldStr.length); | 
|  | if (second >= 0) { | 

| | return { | | | ok: false, | | | text, | | | reason: 'old_str appears more than once', | | | }; | | | } | | | return { | | | ok: true, | | | text: | | | text.slice(0, first) + | | | newStr + | | | text.slice(first + oldStr.length), | | | }; | | | } |

|  | function replayArtifacts(messages) { | 
|  | const artifacts = new Map(); | 
|  | let anonymousCounter = 0; | 
|  | for (const m of messages) { | 
|  | for (const block of asArray(m?.content)) { | 

| | if ( | | | block?.type !== 'tool_use' || | | | block?.name !== 'artifacts' || | | | !block.input || |

|  | typeof block.input !== 'object' | 
|  | ) { | 

| | continue; | | | } |

|  | const input = block.input; | 
|  | const command = String(input.command \|\| 'create').toLowerCase(); | 
|  | let id = firstString( | 

| | input.id, | | | input.artifact_id, | | | input.uuid, | | | input.version_uuid |

|  | ); | 
|  | if (!id) { | 
|  | id = `anonymous-artifact-${++anonymousCounter}`; | 

| | } |

|  | let state = artifacts.get(id); | 
|  | if (!state) { | 
|  | state = { | 

| | id, |

|  | title: firstString(input.title, id), | 
|  | type: firstString(input.type, input.mime_type), | 
|  | language: firstString(input.language), | 

| | content: '', |

|  | warnings: [], | 
|  | }; | 
|  | artifacts.set(id, state); | 

| | } |

|  | if (input.title) state.title = input.title; | 
|  | if (input.type) state.type = input.type; | 
|  | if (input.mime_type) state.type = input.mime_type; | 
|  | if (input.language) state.language = input.language; | 
|  | if (command === 'create') { | 
|  | if (typeof input.content === 'string') { | 
|  | state.content = input.content; | 
|  | } else if (typeof input.new_str === 'string') { | 
|  | state.content = input.new_str; | 

| | } | | | continue; | | | } |

|  | if (command === 'rewrite') { | 
|  | if (typeof input.content === 'string') { | 
|  | state.content = input.content; | 
|  | } else if (typeof input.new_str === 'string') { | 
|  | state.content = input.new_str; | 
|  | } else { | 

| | state.warnings.push( | | | 'rewrite command had no content/new_str' | | | ); | | | } | | | continue; | | | } | | | if (command === 'update') { | | | if ( |

|  | typeof input.old_str === 'string' && | 
|  | typeof input.new_str === 'string' | 
|  | ) { | 

| | const replaced = replaceExactlyOnce( | | | state.content, | | | input.old_str, | | | input.new_str |

|  | ); | 
|  | if (replaced.ok) { | 
|  | state.content = replaced.text; | 
|  | } else if (typeof input.content === 'string') { | 
|  | state.content = input.content; | 

| | state.warnings.push( |

|  | `update patch could not be applied (${replaced.reason}); used full content fallback` | 
|  | ); | 
|  | } else { | 

| | state.warnings.push( | | | update patch could not be applied: ${replaced.reason} | | | ); | | | } |

|  | } else if (typeof input.content === 'string') { | 
|  | state.content = input.content; | 
|  | } else { | 

| | state.warnings.push( | | | 'update command had no usable patch/content' | | | ); | | | } | | | continue; | | | } |

|  | if (typeof input.content === 'string') { | 
|  | state.content = input.content; | 

| | state.warnings.push( | | | unknown command "${command}"; used supplied content |

|  | ); | 
|  | } else { | 

| | state.warnings.push( |

|  | `unknown command "${command}"` | 
|  | ); | 

| | } | | | } | | | } |

|  | return [...artifacts.values()] | 
|  | .filter(a => a.content.length); | 

| | } | | | async function saveReconstructedArtifacts( | | | messages, | | | outputsDir, | | | outputUnique |

|  | ) { | 
|  | const artifacts = replayArtifacts(messages); | 
|  | for (const artifact of artifacts) { | 
|  | const stem = safeName( | 

| | artifact.title || | | | artifact.id || | | | 'artifact' | | | ); | | | const ext = inferArtifactExtension( | | | { | | | type: artifact.type, | | | language: artifact.language, | | | }, | | | artifact.content | | | ); | | | const filename = | | | /.[A-Za-z0-9]{1,10}$/.test(stem) | | | ? stem | | | : ${stem}${ext}; | | | await writeText( | | | outputsDir, | | | outputUnique(filename), | | | artifact.content |

|  | ); | 
|  | if (artifact.warnings.length) { | 

| | console.warn( | | | Artifact "${artifact.title}" reconstructed with warnings:, | | | artifact.warnings | | | ); | | | } | | | } | | | } | | | // ============================================================ | | | // Project-level context and files | | | // ============================================================ | | | async function exportProjectContextAndFiles( | | | orgId, | | | projectId, | | | projectRoot, | | | filesDir |

|  | ) { | 
|  | const base = `${API}/organizations/${orgId}`; | 

| | const [ | | | project, | | | memory, | | | docsRaw, | | | filesRaw, |

|  | ] = await Promise.all([ | 
|  | fetchJSON(`${base}/projects/${projectId}`), | 

| | fetchJSON( | | | ${base}/memory?project_uuid=${encodeURIComponent(projectId)}, | | | true | | | ), | | | fetchJSON( | | | ${base}/projects/${projectId}/docs, | | | true | | | ), | | | fetchJSON( | | | ${base}/projects/${projectId}/files, | | | true | | | ), | | | ]); | | | const instructions = firstString( | | | project?.prompt_template, | | | project?.instructions, | | | project?.project_instructions | | | ); | | | const description = firstString( | | | project?.description, | | | project?.summary, | | | project?.project_description | | | ); | | | await writeText( | | | projectRoot, | | | 'Project Instructions.md', | | | instructions || | | | '(No project instructions were returned by Claude.)\n' | | | ); | | | await writeText( | | | projectRoot, | | | 'Project Memory.md', | | | memoryText(memory) || | | | '(No project memory was returned by Claude.)\n' | | | ); | | | await writeText( | | | projectRoot, | | | 'Project Description.md', | | | description || | | | '(No project description was returned by Claude.)\n' |

|  | ); | 
|  | const docs = asArray(docsRaw); | 
|  | const files = asArray(filesRaw); | 
|  | const unique = makeUniqueNamer(); | 
|  | const originalsRecovered = new Set(); | 
|  | for (const f of files) { | 

| | const sourceName = | | | f.file_name || | | | f.name || |

|  | `file-${f.file_uuid \|\| f.uuid \|\| 'unknown'}`; | 
|  | const candidates = [ | 

| | { | | | url: f.download_url, | | | label: '', | | | original: true, | | | }, | | | { | | | url: f.original_url, | | | label: '', | | | original: true, | | | }, | | | { | | | url: f.original_asset?.url, | | | label: '', | | | original: true, | | | }, | | | { | | | url: f.document_asset?.url, | | | label: ' [Claude representation]', | | | original: false, | | | }, | | | { | | | url: f.preview_asset?.url, | | | label: ' [Claude preview]', | | | original: false, | | | }, | | | { | | | url: f.preview_url, | | | label: ' [Claude preview]', | | | original: false, | | | }, | | | { | | | url: f.thumbnail_asset?.url, | | | label: ' [Claude thumbnail]', | | | original: false, | | | }, |

|  | ].filter(x => x.url); | 
|  | for (const candidate of candidates) { | 

| | try { |

|  | const blob = await fetchBlob( | 
|  | absUrl(candidate.url), | 

| | true |

|  | ); | 
|  | if (!blob) continue; | 
|  | let target = sourceName; | 
|  | if (candidate.label) { | 
|  | const dot = target.lastIndexOf('.'); | 

| | target = | | | dot > 0 |

|  | ? `${target.slice(0, dot)}${candidate.label}${target.slice(dot)}` | 
|  | : `${target}${candidate.label}${extensionFromMime(blob.type)}`; | 

| | } | | | await writeBlob( | | | filesDir, | | | unique(target), | | | blob |

|  | ); | 
|  | if (candidate.original) { | 

| | originalsRecovered.add( |

|  | sourceName.toLocaleLowerCase() | 
|  | ); | 

| | } | | | break; | | | } catch (e) { | | | console.warn( | | | Could not download project file: ${sourceName}, | | | e | | | ); | | | } | | | } | | | } | | | for (const doc of docs) { | | | const sourceName = | | | doc.file_name || | | | doc.name || | | | project-doc-${doc.uuid \|\| doc.id \|\| 'unknown'}; | | | if ( | | | originalsRecovered.has( | | | sourceName.toLocaleLowerCase() | | | ) | | | ) { | | | continue; | | | } | | | const content = | | | typeof doc.content === 'string' | | | ? doc.content | | | : JSON.stringify( | | | doc.content ?? '', | | | null, | | | 2 |

|  | ); | 
|  | if (!content) continue; | 

| | const textLike = | | | /.(md|markdown|txt|csv|json|html|xml|yaml|yml)$/i | | | .test(sourceName); | | | const target = | | | textLike | | | ? sourceName | | | : ${sourceName}.extracted.md; | | | await writeText( | | | filesDir, | | | unique(target), | | | content | | | ); | | | } | | | } | | | // ============================================================ | | | // Per-chat inputs / attachments / outputs | | | // ============================================================ | | | async function exportChatFiles( | | | orgId, | | | chatId, | | | allMessages, | | | activeMessages, | | | attachmentsDir, | | | outputsDir | | | ) { | | | const base = |

|  | `${API}/organizations/${orgId}/conversations/${chatId}/wiggle`; | 
|  | const attachmentUnique = makeUniqueNamer(); | 
|  | const outputUnique = makeUniqueNamer(); | 
|  | const rawUploads = new Set(); | 
|  | const rawOutputs = new Set(); | 

| | try { | | | const listing = await fetchJSON( | | | ${base}/list-files?prefix=, | | | true | | | ); | | | const metadata = | | | asArray(listing?.files_metadata); | | | let paths = metadata |

|  | .map(x => x?.path) | 
|  | .filter(Boolean); | 
|  | if (!paths.length) { | 
|  | paths = asArray( | 

| | listing?.files || | | | listing | | | ); | | | } |

|  | for (const path of paths) { | 
|  | if (typeof path !== 'string') continue; | 

| | const isUpload = | | | path.startsWith( | | | '/mnt/user-data/uploads/' | | | ); | | | const isOutput = | | | path.startsWith( | | | '/mnt/user-data/outputs/' |

|  | ); | 
|  | if (!isUpload && !isOutput) continue; | 

| | const name = | | | path.split('/').pop() || | | | 'file'; | | | try { |

|  | const blob = await fetchBlob( | 
|  | `${base}/download-file?path=${encodeURIComponent(path)}`, | 

| | true |

|  | ); | 
|  | if (!blob) continue; | 
|  | if (isUpload) { | 

| | await writeBlob( | | | attachmentsDir, | | | attachmentUnique(name), | | | blob | | | ); | | | rawUploads.add( |

|  | name.toLocaleLowerCase() | 
|  | ); | 
|  | } else { | 

| | await writeBlob( | | | outputsDir, | | | outputUnique(name), | | | blob | | | ); | | | rawOutputs.add( |

|  | name.toLocaleLowerCase() | 
|  | ); | 

| | } | | | } catch (e) { | | | console.warn( | | | Could not download workspace file: ${path}, | | | e | | | ); | | | } | | | } | | | } catch (e) { | | | console.warn( | | | Could not list conversation workspace files for ${chatId}, | | | e | | | ); | | | } |

|  | let pasteCounter = 0; | 
|  | for (const m of allMessages) { | 
|  | for (const obj of [ | 
|  | ...asArray(m?.attachments), | 
|  | ...asArray(m?.files), | 
|  | ]) { | 

| | const text = | | | typeof obj?.extracted_content === 'string' | | | ? obj.extracted_content |

|  | : ''; | 
|  | if (!text) continue; | 

| | const sourceName = | | | obj.file_name || | | | obj.name || | | | Pasted text ${++pasteCounter}.txt; | | | if ( | | | rawUploads.has( | | | sourceName.toLocaleLowerCase() | | | ) | | | ) { | | | continue; | | | } | | | const saveName = | | | /.[A-Za-z0-9]{1,10}$/.test(sourceName) | | | ? sourceName | | | : ${sourceName}.txt; | | | await writeText( | | | attachmentsDir, | | | attachmentUnique(saveName), | | | text | | | ); | | | rawUploads.add( |

|  | sourceName.toLocaleLowerCase() | 
|  | ); | 

| | } | | | } | | | const recovered = |

|  | new Set(rawUploads); | 
|  | for (const m of allMessages) { | 
|  | for (const obj of [ | 
|  | ...asArray(m?.files), | 
|  | ...asArray(m?.attachments), | 
|  | ]) { | 

| | const sourceName = | | | obj?.file_name || | | | obj?.name || | | | ''; | | | const key = | | | sourceName.toLocaleLowerCase(); | | | if ( | | | key && |

|  | recovered.has(key) | 
|  | ) { | 

| | continue; | | | } | | | const candidates = [ | | | { | | | url: obj?.download_url, | | | label: '', | | | }, | | | { | | | url: obj?.original_url, | | | label: '', | | | }, | | | { | | | url: obj?.original_asset?.url, | | | label: '', | | | }, | | | { | | | url: obj?.document_asset?.url, | | | label: ' [Claude representation]', | | | }, | | | { | | | url: obj?.preview_asset?.url, | | | label: ' [Claude preview]', | | | }, | | | { | | | url: obj?.preview_url, | | | label: ' [Claude preview]', | | | }, | | | { | | | url: obj?.thumbnail_asset?.url, | | | label: ' [Claude thumbnail]', | | | }, | | | { | | | url: obj?.thumbnail_url, | | | label: ' [Claude thumbnail]', | | | }, |

|  | ].filter(x => x.url); | 
|  | if (!candidates.length) continue; | 
|  | for (const candidate of candidates) { | 

| | try { |

|  | const blob = await fetchBlob( | 
|  | absUrl(candidate.url), | 

| | true |

|  | ); | 
|  | if (!blob) continue; | 

| | let name = | | | sourceName || |

|  | `attachment-${obj?.file_uuid \|\| obj?.id \|\| 'unknown'}`; | 
|  | if (candidate.label) { | 

| | const dot = | | | name.lastIndexOf('.'); | | | name = | | | dot > 0 |

|  | ? `${name.slice(0, dot)}${candidate.label}${name.slice(dot)}` | 
|  | : `${name}${candidate.label}`; | 

| | } | | | if ( |

|  | !/\.[A-Za-z0-9]{1,10}$/.test(name) | 
|  | ) { | 

| | name += | | | extensionFromMime(blob.type); | | | } | | | await writeBlob( | | | attachmentsDir, | | | attachmentUnique(name), | | | blob |

|  | ); | 
|  | if (key) { | 
|  | recovered.add(key); | 

| | } | | | break; | | | } catch (e) { | | | console.warn( | | | Could not recover message attachment: ${sourceName \|\| '(unnamed)'}, | | | e | | | ); | | | } | | | } | | | } | | | } | | | await saveReconstructedArtifacts( | | | activeMessages, | | | outputsDir, | | | outputUnique |

|  | ); | 
|  | for (const m of activeMessages) { | 
|  | for (const block of asArray(m?.content)) { | 

| | if ( | | | block?.type !== 'tool_use' || | | | block?.name !== 'create_file' || | | | !block.input | | | ) { | | | continue; | | | } |

|  | const input = block.input; | 
|  | const path = firstString( | 

| | input.path, | | | input.file_path, | | | input.filename, | | | input.name |

|  | ); | 
|  | const content = firstString( | 

| | input.content, | | | input.text, | | | input.file_content |

|  | ); | 
|  | if (!path \|\| !content) continue; | 

| | const filename = |

|  | path.split('/').pop() \|\| | 
|  | 'generated-file.txt'; | 

| | if ( | | | rawOutputs.has( | | | filename.toLocaleLowerCase() | | | ) | | | ) { | | | continue; | | | } | | | await writeText( | | | outputsDir, | | | outputUnique(filename), | | | content | | | ); | | | rawOutputs.add( |

|  | filename.toLocaleLowerCase() | 
|  | ); | 

| | } | | | } | | | } | | | // ============================================================ | | | // UI |

|  | // ============================================================ | 
|  | let statusNode = null; | 
|  | function setStatus(text) { | 

| | console.log( | | | 'Claude exporter:', | | | text |

|  | ); | 
|  | if (statusNode) { | 

| | statusNode.textContent = | | | text; | | | } | | | } | | | function buildButton() { | | | document | | | .getElementById( | | | 'claude-simple-exporter' | | | ) | | | ?.remove(); | | | const wrap = | | | document.createElement('div'); | | | wrap.id = | | | 'claude-simple-exporter'; | | | Object.assign( | | | wrap.style, | | | { | | | position: 'fixed', | | | right: '18px', | | | bottom: '18px', | | | zIndex: '2147483647', | | | display: 'flex', | | | flexDirection: 'column', | | | alignItems: 'flex-end', | | | gap: '8px', | | | fontFamily: | | | 'system-ui, sans-serif', | | | } | | | ); | | | statusNode = | | | document.createElement('div'); | | | Object.assign( | | | statusNode.style, | | | { | | | display: 'none', | | | maxWidth: '460px', | | | padding: '8px 10px', | | | borderRadius: '8px', | | | background: | | | 'rgba(20,20,20,.92)', | | | color: '#fff', | | | fontSize: '12px', | | | } | | | ); | | | const btn = | | | document.createElement('button'); | | | btn.textContent = | | | 'Export Claude Project'; | | | Object.assign( | | | btn.style, | | | { | | | padding: '10px 14px', | | | borderRadius: '10px', | | | cursor: 'pointer', | | | border: | | | '1px solid rgba(127,127,127,.35)', | | | background: '#f0eee6', | | | color: '#222', | | | fontWeight: '650', | | | } | | | ); | | | wrap.append( | | | statusNode, | | | btn | | | ); | | | document.body.appendChild( | | | wrap | | | ); | | | return btn; | | | } | | | // ============================================================ | | | // Run | | | // ============================================================ | | | const projects = | | | await discoverProjects(); | | | const btn = | | | buildButton(); | | | console.log( |

|  | `Claude exporter ready. Found ${projects.length} project(s).` | 
|  | ); | 

| | btn.addEventListener( | | | 'click', | | | async () => { | | | try { | | | const selected = |

|  | chooseProject(projects); | 
|  | if (!selected) return; | 

| | if ( | | | !window.showDirectoryPicker | | | ) { | | | throw new Error( | | | 'Use Chrome or Edge: this browser cannot choose an export folder.' | | | ); | | | } | | | const destination = | | | await window.showDirectoryPicker({ | | | mode: 'readwrite', |

|  | }); | 
|  | btn.disabled = true; | 

| | statusNode.style.display = | | | 'block'; | | | const orgId = | | | selected.org.uuid; | | | const projectId = | | | selected.project.uuid; | | | const projectName = | | | selected.project.name || | | | 'Untitled Claude Project'; | | | const projectRoot = | | | await getDir( | | | destination, | | | projectName | | | ); | | | const filesDir = | | | await getDir( | | | projectRoot, | | | 'Files' | | | ); | | | const chatsRoot = | | | await getDir( | | | projectRoot, | | | 'Chats' | | | ); | | | setStatus( | | | 'Exporting project instructions, memory, description and files…' | | | ); | | | await exportProjectContextAndFiles( | | | orgId, | | | projectId, | | | projectRoot, | | | filesDir | | | ); | | | const conversations = | | | asArray( | | | await fetchJSON( | | | ${API}/organizations/${orgId}/projects/${projectId}/conversations | | | ) | | | ); | | | const uniqueChatFolder = | | | makeUniqueNamer(); | | | for ( | | | let i = 0; | | | i < conversations.length; | | | i++ | | | ) { | | | const c = | | | conversations[i]; | | | const chatId = | | | c.uuid || | | | c.id; | | | const title = | | | c.name || | | | c.title || | | | 'Untitled chat'; | | | setStatus( |

|  | `Exporting ${i + 1}/${conversations.length}: ${title}` | 
|  | ); | 

| | const raw = | | | await fetchJSON( | | | ${API}/organizations/${orgId}/chat_conversations/${chatId} + | | | ?tree=True&rendering_mode=messages&render_all_tools=true&consistency=eventual | | | ); | | | const allMessages = | | | asArray( | | | raw.chat_messages | | | ); | | | const activeMessages = | | | activeBranch( | | | allMessages, | | | raw.current_leaf_message_uuid | | | ); | | | const chatFolderName = | | | uniqueChatFolder(title); | | | const chatDir = | | | await getDir( | | | chatsRoot, | | | chatFolderName | | | ); | | | const attachmentsDir = | | | await getDir( | | | chatDir, | | | 'Attachments' | | | ); | | | const outputsDir = | | | await getDir( | | | chatDir, | | | 'Outputs' | | | ); | | | await writeText( | | | chatDir, | | | ${safeName(title)}.md, | | | renderChatMarkdown( | | | { ...c, ...raw }, | | | activeMessages, | | | projectName | | | ) | | | ); | | | await exportChatFiles( | | | orgId, | | | chatId, | | | allMessages, | | | activeMessages, | | | attachmentsDir, | | | outputsDir |

|  | ); | 
|  | await sleep(100); | 

| | } | | | setStatus( | | | Done — ${conversations.length} chats exported. | | | ); | | | btn.textContent = | | | 'Export complete'; | | | } catch (e) { | | | console.error( | | | 'Claude exporter failed:', | | | e | | | ); | | | statusNode.style.display = | | | 'block'; | | | statusNode.textContent = |

|  | `Export failed: ${e.message \|\| e}`; | 
|  | btn.disabled = false; | 

| | } | | | } |

|  | ); | 
|  | })(); |
── more in #ai-tools 4 stories · sorted by recency
── more on @claude 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
→ Live at https://your-agent.zahid.host ✓
Get free account → Pricing
from €0/mo · no card required
LIVE [news/claude-project-expor…] indexed:0 read:35min 2026-10-05 · —