Claude Chat Exporter

Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Claude Chat Exporter
// @namespace    foxbinner
// @version      1.3.0
// @description  Export any Claude.ai conversation as Markdown, Plain Text, or JSON with one click — adds a clean Export button to the chat toolbar.
// @license      MIT
// @match        *://claude.ai/*
// @icon         https://claude.ai/favicon.ico
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  // ── Styles ─────────────────────────────────────────────────────────────────

  function injectStyles() {
    if (document.getElementById('_exp_styles')) return;
    const s = document.createElement('style');
    s.id = '_exp_styles';
    s.textContent = `
      #_exp_wrap { position: relative; display: inline-flex; }

      #_exp_menu {
        display: none;
        position: absolute;
        top: calc(100% + 6px);
        right: 0;
        z-index: 9999;
        min-width: 148px;
        padding: 4px;
        border-radius: 8px;
        overflow: hidden;
        background: #2b2b2b;
        border: 1px solid rgba(255,255,255,0.09);
        box-shadow: 0 4px 12px rgba(0,0,0,0.38);
      }
      #_exp_menu.open { display: block; }

      .exp-item {
        display: flex;
        align-items: center;
        gap: 8px;
        width: 100%;
        padding: 6px 10px;
        border-radius: 5px;
        border: none;
        background: transparent;
        cursor: pointer;
        text-align: left;
        font-size: 12px;
        font-weight: 600;
        font-family: inherit;
        color: rgba(255,255,255,0.82);
        transition: background 100ms ease, color 100ms ease;
      }
      .exp-item svg { flex-shrink: 0; opacity: 0.65; transition: opacity 100ms ease; }
      .exp-item:hover { background: rgba(255,255,255,0.10); color: #fff; }
      .exp-item:hover svg { opacity: 0.90; }
      .exp-item:disabled { opacity: 0.4; cursor: not-allowed; pointer-events: none; }
    `;
    document.head.appendChild(s);
  }

  // ── Icons ──────────────────────────────────────────────────────────────────

  const ICON = {
    download: `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
    markdown: `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>`,
    plain:    `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M4 12h16M4 18h12"/></svg>`,
    json:     `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H7a2 2 0 0 0-2 2v5a2 2 0 0 1-2 2 2 2 0 0 1 2 2v5c0 1.1.9 2 2 2h1"/><path d="M16 3h1a2 2 0 0 1 2 2v5a2 2 0 0 0 2 2 2 2 0 0 0-2 2v5a2 2 0 0 1-2 2h-1"/></svg>`,
  };

  // ── API Data Fetching ───────────────────────────────────────────────────────

  function getConversationId() {
    const m = location.pathname.match(/\/chat\/([0-9a-f-]{16,})/i);
    return m?.[1] ?? null;
  }

  function getOrgId() {
    // Claude stores the active org in a cookie: lastActiveOrg=<uuid>
    return document.cookie.match(/lastActiveOrg=([^;]+)/)?.[1] ?? null;
  }

  async function fetchMessages() {
    const convId = getConversationId();
    const orgId  = getOrgId();

    if (!convId || !orgId) {
      throw new Error(
        `Could not resolve IDs — convId: ${convId}, orgId: ${orgId}. ` +
        `Make sure you are on a /chat/<id> page and logged in.`
      );
    }

    const url = `/api/organizations/${orgId}/chat_conversations/${convId}` +
      `?tree=true&rendering_mode=messages&render_all_tools=true`;

    const res = await fetch(url, {
      credentials: 'include',
      headers: { 'Content-Type': 'application/json' },
    });

    if (!res.ok) throw new Error(`API returned ${res.status} ${res.statusText}`);

    const data = await res.json();
    return { data, thread: buildActiveThread(data) };
  }

  // Walk the message tree from the current leaf → root, then reverse.
  // This gives only the active branch, ignoring edited/branched siblings.
  function buildActiveThread(data) {
    const messages = data?.chat_messages ?? [];
    if (!messages.length) return [];

    const byUuid = new Map(messages.map(m => [m.uuid, m]));
    const leafId = data.current_leaf_message_uuid ?? findLeaf(messages);

    const chain = [];
    const seen  = new Set();
    let cur = byUuid.get(leafId) ?? messages[messages.length - 1];

    while (cur && !seen.has(cur.uuid)) {
      seen.add(cur.uuid);
      chain.push(cur);
      cur = cur.parent_message_uuid ? byUuid.get(cur.parent_message_uuid) : null;
    }

    return chain.reverse();
  }

  function findLeaf(messages) {
    const hasChildren = new Set(messages.map(m => m.parent_message_uuid).filter(Boolean));
    const leaves = messages.filter(m => !hasChildren.has(m.uuid));
    leaves.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
    return leaves[0]?.uuid ?? messages[messages.length - 1]?.uuid;
  }

  // ── Title Resolution ────────────────────────────────────────────────────────

  function resolveTitle(data) {
    const api = data?.name?.trim();
    if (api && !/^new conversation$/i.test(api)) return api;

    const dom = document.querySelector(
      '[data-testid="chat-title-button"] .truncate, [data-testid="chat-title-button"]'
    )?.textContent?.trim();
    if (dom && !/^(claude|new conversation)$/i.test(dom)) return dom;

    return document.title.replace(/\s*[–-]\s*Claude.*$/i, '').trim() || 'claude-chat';
  }

  // ── Content Rendering ───────────────────────────────────────────────────────

  // Internal Claude tool names — system plumbing, not conversation content.
  // Exporting their inputs/results pollutes files with unrelated data
  // (memory search dumps from other chats, file reads, bash output, etc.)
  const INTERNAL_TOOLS = new Set([
    'conversation_search', 'recent_chats',
    'web_search', 'web_fetch',
    'bash_tool', 'view', 'str_replace',
    'create_file', 'present_files',
    'image_search', 'weather_fetch',
    'places_search', 'places_map_display_v0',
    'fetch_sports_data', 'ask_user_input_v0',
    'search_mcp_registry', 'suggest_connectors',
    'visualize:read_me', 'visualize:show_widget',
  ]);

  // Flatten a message's structured content into plain text.
  // Only text blocks and user-uploaded attachments are kept.
  // Internal tool calls and ALL tool_results are stripped.
  function messageToText(msg) {
    const parts = Array.isArray(msg.content) ? msg.content : [];
    const out   = [];

    for (const p of parts) {
      if (p?.type === 'text' && p.text) {
        out.push(p.text.trim());

      } else if (p?.type === 'thinking') {
        const t = (p.thinking ?? p.text ?? '').trim();
        if (t) out.push(`[thinking]\n${t}`);

      } else if (p?.type === 'tool_use') {
        // Skip internal system tools entirely
        if (INTERNAL_TOOLS.has(p.name)) continue;
        // Non-internal tool_use (rare in normal chat) — mention briefly
        out.push(`[tool: ${p.name ?? 'unknown'}]`);

      } else if (p?.type === 'tool_result') {
        // Always skip — contains raw system data irrelevant to the conversation
        continue;

      } else if (p?.type === 'image') {
        out.push('[image]');
      }
    }

    // User-uploaded attachments are part of the conversation, keep them
    for (const att of [...(msg.attachments ?? []), ...(msg.files_v2 ?? [])]) {
      const name    = att.file_name ?? att.name ?? 'attachment';
      const content = att.extracted_content ?? att.content ?? '';
      out.push(content ? `[attachment: ${name}]\n${content}` : `[attachment: ${name}]`);
    }

    return out.join('\n\n').replace(/\n{3,}/g, '\n\n').trim();
  }

  // ── Export Formatters ───────────────────────────────────────────────────────

  // Single source of truth for all metadata — every format pulls from this.
  function buildMeta(thread, title, url, data) {
    const timestamps = thread.map(m => m.created_at).filter(Boolean);
    return {
      title,
      url,
      exportedAt:      new Date().toISOString(),
      conversation_id: data?.uuid  ?? null,
      model:           data?.model ?? null,
      message_count:   thread.length,
      started_at:      timestamps[0]                   ?? null,
      ended_at:        timestamps[timestamps.length - 1] ?? null,
    };
  }

  function fmtTs(iso) {
    if (!iso) return null;
    try {
      return new Date(iso).toLocaleString(undefined, {
        year: 'numeric', month: 'short', day: 'numeric',
        hour: 'numeric', minute: '2-digit',
      });
    } catch { return iso; }
  }

  function toMarkdown(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    const header = [
      `# ${m.title}`,
      '',
      `| Field | Value |`,
      `|---|---|`,
      `| Conversation ID | \`${m.conversation_id ?? '—'}\` |`,
      `| Model           | ${m.model ?? '—'} |`,
      `| Messages        | ${m.message_count} |`,
      `| Started         | ${fmtTs(m.started_at) ?? '—'} |`,
      `| Ended           | ${fmtTs(m.ended_at)   ?? '—'} |`,
      `| Exported        | ${fmtTs(m.exportedAt)} |`,
      `| Source          | ${m.url} |`,
      '',
      '---',
      '',
    ].join('\n');

    const body = thread.map(msg => {
      const label = msg.sender === 'human' ? '🧑 You' : '🤖 Claude';
      const ts    = fmtTs(msg.created_at);
      const heading = ts ? `## ${label} — ${ts}` : `## ${label}`;
      return `${heading}\n\n${messageToText(msg) || '*[empty]*'}\n`;
    }).join('\n---\n\n');

    return header + body;
  }

  function toPlain(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    const sep = '='.repeat(Math.min(title.length, 60));
    const header = [
      m.title,
      sep,
      `Conversation ID : ${m.conversation_id ?? '—'}`,
      `Model           : ${m.model           ?? '—'}`,
      `Messages        : ${m.message_count}`,
      `Started         : ${fmtTs(m.started_at) ?? '—'}`,
      `Ended           : ${fmtTs(m.ended_at)   ?? '—'}`,
      `Exported        : ${fmtTs(m.exportedAt)}`,
      `Source          : ${m.url}`,
      sep,
      '',
    ].join('\n');

    const body = thread.map(msg => {
      const label = msg.sender === 'human' ? 'You' : 'Claude';
      const ts    = fmtTs(msg.created_at);
      const heading = ts ? `[${label} — ${ts}]` : `[${label}]`;
      return `${heading}\n${messageToText(msg) || '(empty)'}`;
    }).join('\n\n');

    return header + body + '\n';
  }

  function toJSON(thread, title, url, data) {
    const m = buildMeta(thread, title, url, data);
    return JSON.stringify({
      ...m,
      messages: thread.map(msg => ({
        uuid:       msg.uuid,
        sender:     msg.sender,
        created_at: msg.created_at,
        text:       messageToText(msg),
        content:    msg.content,   // raw structured content for power users
      })),
    }, null, 2);
  }

  // ── Download ────────────────────────────────────────────────────────────────

  function triggerDownload(filename, content, mime) {
    const url = URL.createObjectURL(new Blob([content], { type: mime }));
    Object.assign(document.createElement('a'), { href: url, download: filename }).click();
    setTimeout(() => URL.revokeObjectURL(url), 3000);
  }

  // ── Export Entry Point ──────────────────────────────────────────────────────

  async function runExport(format) {
    const items = document.querySelectorAll('.exp-item');
    items.forEach(b => (b.disabled = true));

    try {
      const { data, thread } = await fetchMessages();

      if (!thread.length) throw new Error('The API returned 0 messages.');

      const title = resolveTitle(data);
      const url   = location.href;

      if (format === 'markdown') {
        triggerDownload(`${title}.md`,   toMarkdown(thread, title, url, data), 'text/markdown');
      } else if (format === 'plain') {
        triggerDownload(`${title}.txt`,  toPlain(thread, title, url, data),    'text/plain');
      } else if (format === 'json') {
        triggerDownload(`${title}.json`, toJSON(thread, title, url, data), 'application/json');
      }
    } catch (err) {
      alert(`Export failed: ${err.message}`);
      console.error('[Claude Exporter]', err);
    } finally {
      items.forEach(b => (b.disabled = false));
    }
  }

  // ── UI ──────────────────────────────────────────────────────────────────────

  const FORMATS = [
    { label: 'Markdown',   icon: ICON.markdown, fmt: 'markdown' },
    { label: 'Plain Text', icon: ICON.plain,     fmt: 'plain'    },
    { label: 'JSON',       icon: ICON.json,      fmt: 'json'     },
  ];

  function buildWidget() {
    const wrap = document.createElement('div');
    wrap.id = '_exp_wrap';

    const btn = document.createElement('button');
    btn.id = '_exp_btn';
    btn.type = 'button';
    btn.className =
      'inline-flex items-center justify-center relative isolate shrink-0 can-focus select-none ' +
      'disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none disabled:drop-shadow-none ' +
      'font-base-bold border-0.5 overflow-hidden transition duration-100 backface-hidden ' +
      'h-8 rounded-md px-3 min-w-[4rem] whitespace-nowrap !text-xs _fill_10ocf_9 _secondary_10ocf_72';
    btn.innerHTML = `${ICON.download}Export`;

    const menu = document.createElement('div');
    menu.id = '_exp_menu';
    menu.role = 'menu';

    for (const { label, icon, fmt } of FORMATS) {
      const item = document.createElement('button');
      item.type = 'button';
      item.className = 'exp-item';
      item.role = 'menuitem';
      item.innerHTML = `${icon}${label}`;
      item.addEventListener('click', () => {
        menu.classList.remove('open');
        runExport(fmt);
      });
      menu.appendChild(item);
    }

    wrap.append(btn, menu);

    btn.addEventListener('click', e => { e.stopPropagation(); menu.classList.toggle('open'); });
    document.addEventListener('click', e => { if (!wrap.contains(e.target)) menu.classList.remove('open'); });
    document.addEventListener('keydown', e => { if (e.key === 'Escape') menu.classList.remove('open'); });

    return wrap;
  }

  // ── Bootstrap ───────────────────────────────────────────────────────────────

  function inject() {
    if (!/^\/chat\//.test(location.pathname)) {
      document.getElementById('_exp_wrap')?.remove();
      return;
    }

    if (document.getElementById('_exp_wrap')) return;

    const shareBtn = document.querySelector('[data-testid="wiggle-controls-actions-share"]');
    if (!shareBtn) return;

    injectStyles();
    shareBtn.before(buildWidget());
  }

  // Intercept pushState for SPA sidebar navigations
  const _origPush = history.pushState.bind(history);
  history.pushState = function (...args) {
    _origPush(...args);
    setTimeout(inject, 300);
  };
  window.addEventListener('popstate', () => setTimeout(inject, 300));

  inject();
  new MutationObserver(inject).observe(document.body, { childList: true, subtree: true });

})();