Export a Claude conversation to PDF, Word or Markdown

Save the Claude chat you have open as a PDF, a Word document or Markdown, in the right order, with code blocks, tables and formulas intact.

Open the conversation you want on claude.ai, then run the script there.

export-claude.js · 202 linespaste in the DevTools console on claude.ai
// Responsive Muse · Claude Conversation Exporter · v1.0 · 2026-10-22
// Saves the Claude conversation you have open as Markdown, Word (.doc) or PDF, with code
// blocks, lists and tables kept. Runs only on claude.ai. Claude only keeps part of a long
// chat on screen, so the script scrolls from the top and collects every message on the way.
// It makes no network requests of its own and never reads cookies, passwords or tokens.
(async () => {
  const TOOL = 'Export Claude chat';
  const NAMES = { user: 'You', ai: 'Claude' };     // headings used in the file
  const DELAY = 900;                               // pause between scrolls, in milliseconds

  if (location.hostname !== 'claude.ai' || !location.pathname.startsWith('/chat/')) {
    alert('Open the Claude conversation you want to export first (claude.ai/chat/…).');
    return;
  }
  // A small box on the page shows progress and asks before changing anything,
  // so you never need to read or edit the console.
  const many = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;   // 1 video, 2 videos
  const panel = (() => {
    const box = document.createElement('div');
    box.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:2147483647;width:340px;max-width:calc(100vw - 32px);' +
      'background:#111318;color:#e6e8ee;font:14px/1.5 system-ui,sans-serif;border-radius:12px;padding:16px;' +
      'box-shadow:0 8px 30px rgba(0,0,0,.4);text-align:left';
    const head = document.createElement('strong');
    head.textContent = TOOL;
    const msg = document.createElement('p');
    msg.style.cssText = 'margin:8px 0 12px;white-space:pre-line';
    const row = document.createElement('div');
    row.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:flex-end';
    box.append(head, msg, row);
    document.body.append(box);
    const button = (label, primary, onClick) => {
      const b = document.createElement('button');
      b.textContent = label;
      b.style.cssText = 'font:600 14px system-ui,sans-serif;padding:8px 14px;border-radius:8px;cursor:pointer;' +
        (primary ? 'background:#0f766e;color:#fff;border:1px solid #0f766e' : 'background:none;color:#e6e8ee;border:1px solid #3a3f4b');
      b.onclick = onClick;
      row.append(b);
    };
    return {
      text(t) { msg.textContent = t; },
      ask(t, yes) {
        msg.textContent = t;
        row.replaceChildren();
        return new Promise(r => {
          button('Cancel', false, () => { row.replaceChildren(); r(false); });
          button(yes, true, () => { row.replaceChildren(); r(true); });
        });
      },
      stoppable(onStop) { row.replaceChildren(); button('Stop', false, onStop); },
      actions(t, list) {
        msg.textContent = t;
        row.replaceChildren();
        list.forEach(([label, primary, run]) => button(label, primary, run));
      },
      finish(t) { this.actions(t, [['Close', true, () => box.remove()]]); },
      close() { box.remove(); },
    };
  })();
  // Turn a rendered message (HTML) back into Markdown: headings, lists, code, tables, links
  const toMarkdown = root => {
    const skip = el => el.matches('button, svg, script, style, [aria-hidden="true"], .sr-only, [data-markdown-copy="exclude"], ' +
      '.code-block-decoration, [class*="code-block-header"]');
    const walk = (node, depth) => {
      if (node.nodeType === 3) return node.textContent;
      if (node.nodeType !== 1 || skip(node)) return '';
      const kids = () => [...node.childNodes].map(n => walk(n, depth)).join('');
      const tag = node.tagName.toLowerCase();
      // Math (KaTeX) is on the page three times; keep only its TeX source
      if (node.classList.contains('katex')) {
        const tex = node.querySelector('annotation[encoding="application/x-tex"]')?.textContent.trim();
        if (tex) return node.closest('.katex-display') ? `\n\n$$${tex}$$\n\n` : `$${tex}$`;
      }
      if (/^h[1-6]$/.test(tag)) return `\n\n${'#'.repeat(+tag[1])} ${kids().trim()}\n\n`;
      if (tag === 'p') return `\n\n${kids().trim()}\n\n`;
      if (tag === 'br') return '\n';
      if (tag === 'hr') return '\n\n---\n\n';
      if (tag === 'strong' || tag === 'b') return `**${kids()}**`;
      if (tag === 'em' || tag === 'i') return `*${kids()}*`;
      if (tag === 'a') return `[${kids()}](${node.href})`;
      if (tag === 'img') return node.alt ? `![${node.alt}](${node.src})` : '';
      if (tag === 'pre') {
        const code = node.querySelector('code') || node;
        const lang = ((code.className || '').match(/language-([\w+-]+)/) || [])[1] || '';
        return `\n\n\`\`\`${lang}\n${code.textContent.replace(/\n$/, '')}\n\`\`\`\n\n`;
      }
      if (tag === 'code') return '`' + node.textContent + '`';
      if (tag === 'blockquote') return '\n\n' + kids().trim().split('\n').map(l => '> ' + l).join('\n') + '\n\n';
      if (tag === 'ul' || tag === 'ol') {
        const items = [...node.children].filter(li => li.tagName === 'LI').map((li, i) => {
          const body = [...li.childNodes].map(n => walk(n, depth + 1)).join('').trim().replace(/\n{2,}/g, '\n');
          return '  '.repeat(depth) + (tag === 'ol' ? `${i + 1}. ` : '- ') + body;
        });
        return '\n' + items.join('\n') + '\n';
      }
      if (tag === 'table') {
        const rows = [...node.querySelectorAll('tr')].map(tr =>
          '| ' + [...tr.children].map(c => walk(c, depth).trim().replace(/\|/g, '\\|').replace(/\n/g, ' ')).join(' | ') + ' |');
        if (rows.length) rows.splice(1, 0, '|' + ' --- |'.repeat(rows[0].split(' | ').length));
        return '\n\n' + rows.join('\n') + '\n\n';
      }
      return kids();
    };
    return walk(root, 0).replace(/\n{3,}/g, '\n\n').trim();
  };
  // Offer the chat as Markdown, Word or PDF (all built here, in your browser)
  const deliverChat = (title, messages, names, note = '') => {
    const date = new Date().toISOString().slice(0, 10);
    const md = `# ${title}\n\n_Exported ${date} from ${location.href}_\n\n` + messages.map(m =>
      `## ${m.role === 'user' ? names.user : names.ai}\n\n${m.markdown}`).join('\n\n---\n\n') + '\n';
    const esc = s => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    const html = '<!doctype html><html><head><meta charset="utf-8"><title>' + esc(title) + '</title><style>' +
      'body{font:15px/1.6 Georgia,serif;max-width:760px;margin:40px auto;padding:0 20px;color:#111}' +
      'h1{font:700 26px system-ui,sans-serif}h2{font:600 15px system-ui,sans-serif;text-transform:uppercase;letter-spacing:.05em;color:#555;margin-top:32px}' +
      '.msg{white-space:pre-wrap}.user{background:#f3f4f6;padding:12px 16px;border-radius:8px}' +
      'pre,code{font:13px Consolas,monospace}hr{border:0;border-top:1px solid #ddd}</style></head><body>' +
      `<h1>${esc(title)}</h1><p><em>Exported ${date}</em></p>` + messages.map(m =>
        `<h2>${esc(m.role === 'user' ? names.user : names.ai)}</h2><div class="msg ${m.role}">${esc(m.markdown)}</div>`).join('<hr>') +
      '</body></html>';
    const file = (title || 'chat').replace(/[\\/:*?"<>|]+/g, '_').slice(0, 80);
    const save = (body, ext, type) => {
      const a = document.createElement('a');
      a.href = URL.createObjectURL(new Blob([body], { type }));
      a.download = `${file}.${ext}`;
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 5000);
      panel.text(`Saved as ${file}.${ext} (check your Downloads).`);
    };
    const pdf = () => {
      const w = window.open('', '_blank');
      if (!w) { panel.text('Your browser blocked the new window. Allow pop-ups for this site and try again.'); return; }
      w.document.write(html);
      w.document.close();
      w.focus();
      w.print();
      panel.text('Choose "Save as PDF" as the printer in the window that opened.');
    };
    const copy = () => navigator.clipboard.writeText(md)
      .then(() => panel.text('Copied as Markdown. Paste it into Notion, Obsidian or any editor.'))
      .catch(() => panel.text('The browser blocked the clipboard: use Markdown instead.'));
    panel.actions(`Found ${many(messages.length, 'message')}.${note ? '\n' + note : ''}`, [
      ['Copy', false, copy],
      ['Markdown', false, () => save(md, 'md', 'text/markdown;charset=utf-8')],
      ['Word', false, () => save('\ufeff' + html, 'doc', 'application/msword')],
      ['PDF', true, pdf],
    ]);
  };
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const SEL = '[data-testid="user-message"], [data-testid="assistant-message"]';
  const first = document.querySelector(SEL);
  let box = first;
  while (box && !(box.scrollHeight > box.clientHeight + 50 && /(auto|scroll)/.test(getComputedStyle(box).overflowY))) box = box.parentElement;
  if (!first || !box) {
    panel.finish('No messages found. Claude may have changed its layout: please report it on responsive-muse.com.');
    return 0;
  }

  // 1. Scroll to the very top until no older messages load
  let last = -1, idle = 0;
  while (idle < 3) {
    box.scrollTop = 0;
    await sleep(DELAY);
    const n = document.querySelectorAll(SEL).length;
    idle = n === last ? idle + 1 : 0;
    last = n;
    panel.text('Loading the start of the conversation…');
  }

  // 2. Scroll down, collecting each message once. Claude numbers every message (data-index),
  //    which keeps the right order even though it re-inserts rows while you scroll.
  const seen = new Map();
  const collect = () => {
    for (const m of document.querySelectorAll(SEL)) {
      const role = m.getAttribute('data-testid') === 'user-message' ? 'user' : 'ai';
      const index = Number(m.closest('[data-index]')?.getAttribute('data-index'));
      const key = Number.isFinite(index) ? index : (m.closest('[data-turn-key]')?.getAttribute('data-turn-key') || '') + role;
      if (seen.has(key)) continue;
      const body = role === 'ai' ? (m.querySelector('.standard-markdown') || m) : m;
      seen.set(key, { index, role, markdown: role === 'ai' ? toMarkdown(body) : body.textContent.trim() });
    }
  };
  idle = 0;
  while (idle < 3) {
    const before = seen.size;
    collect();
    const atEnd = box.scrollTop + box.clientHeight >= box.scrollHeight - 5;
    box.scrollTop += box.clientHeight * 0.8;
    await sleep(DELAY);
    idle = seen.size === before && atEnd ? idle + 1 : 0;
    panel.text(`Reading messages… ${many(seen.size, 'message')}`);
  }
  const messages = [...seen.values()].sort((a, b) => a.index - b.index);
  const numbered = messages.filter(m => Number.isFinite(m.index));
  const missing = numbered.length ? numbered.at(-1).index + 1 - numbered.length : 0;
  const note = missing > 0 ? `${many(missing, 'message')} could not be read: scroll through the chat once and run it again.` : '';
  if (!messages.length) {
    panel.finish('No messages found. Claude may have changed its layout: please report it on responsive-muse.com.');
    return 0;
  }
  deliverChat(document.title.replace(/\s*-\s*Claude$/, '') || 'Claude conversation', messages, NAMES, note);
  return messages.length;
})();

Bookmarklet: drag the middle button to your bookmarks bar, open claude.ai and click the bookmark. It runs the same script with the options above.

Why it's safe to runAutomated check passed Oct 23, 2026 · tested on a real account Oct 22, 2026
  • Sends nothing anywhere. No fetch, XHR or external scripts. The only network activity is claude.ai itself loading your list.
  • Never touches your login. It doesn't read cookies, passwords, tokens or browser storage.
  • Only runs on claude.ai. Anywhere else it stops before doing anything.
  • Short and readable. 202 commented lines, not minified or obfuscated.
  • Shows its progress on the page. A small box tells you what it's doing and offers Download or Copy at the end.
  • Same code for everyone. SHA-256 of the default version: cc723f362ba0c640…
Verify it yourself in 30 seconds
  1. Read it: every line is above, with comments.
  2. Search the code for fetch, XMLHttpRequest, cookie, localStorage or eval. None of them appear.
  3. Open the Network tab before running it: you'll only see requests to claude.ai.
Did it work for you?

How to use it

  1. Open the conversation on claude.ai while signed in.
  2. Open the console: F12 or Ctrl+Shift+J (Cmd+Option+J on Mac). If the browser asks, type allow pasting and press Enter.
  3. Copy the script, paste it in the console and press Enter. It scrolls through the chat, then a small box offers Copy, Markdown, Word and PDF.
  4. For a PDF, click PDF and choose “Save as PDF” as the printer.

Keeps every message in order

On long chats Claude only keeps the messages near your screen and adds the rest back as you scroll, sometimes in a different place. Scripts that read the page once lose messages or mix up the order. This one uses the number Claude gives each message, so the file always follows the conversation, and it tells you if anything couldn’t be read.

FAQ

Does Claude have an export button?

Claude can export your whole account from Settings, as a zip of JSON files sent by email. There is no button to save one conversation as a document; this script does that.

Are code blocks, tables and math kept?

Yes. Code keeps its language, tables become Markdown tables and math is exported as TeX ($…$), which Notion, Obsidian and most Markdown editors display as formulas.

How do I get a PDF?

Click PDF in the box: a clean print view opens. Choose "Save as PDF" as the printer. Nothing is uploaded anywhere.

Does it work with very long chats?

Yes. The site only keeps part of a long chat on screen, so the script scrolls from the top and collects every message on the way. Keep the tab open until the box shows the buttons.

Will messages be out of order?

No. Claude numbers every message, and the script uses those numbers. If any message can't be read, the box tells you.

Export chats from other AIs

Export & back up