Export Cursor Chat History from state.vscdb

June 2026 · Published by Amar Kumar

Cursor has no built-in “export all chats” button. Every Composer thread, Agent run, and Plan file is already on disk — mostly inside SQLite files named state.vscdb. If you want backups, compliance archives, or a path off Cursor, you read those files locally.

This guide is a generic, read-only approach: list every composer, reconstruct message order correctly, redact secrets, and write JSON + Markdown. No cloud API, no auth token in your export bundle.

Next step: Once exported, see Migrate Cursor Chat to Claude Code for turning transcripts into Claude project context.

Where Cursor stores chats

Paths below are for macOS. Linux uses ~/.config/Cursor/; Windows uses %APPDATA%\Cursor\. The export script resolves these automatically.

PathWhat it holds
…/User/globalStorage/state.vscdbGlobal composer index, chat bubbles, blobs
…/User/workspaceStorage/<hash>/state.vscdbPer-workspace aiService.prompts history
~/.cursor/plans/*.plan.mdPlan mode markdown outputs
~/.cursor/projects/<slug>/agent-transcripts/Agent run JSONL logs
~/.cursor/ai-tracking/ai-code-tracking.dbConversation summaries, AI authorship stats
…/anysphere.cursor-commits/checkpoints/Before/after snapshots for AI edits

SQLite schema

state.vscdb has two tables that matter for chat export:

ItemTable keys (global)

cursorDiskKV key prefixes

PrefixContents
composerData:<composerId>Thread metadata + fullConversationHeadersOnly (true message order)
bubbleId:<composerId>:<bubbleId>One user or assistant message
messageRequestContext:…Request context per bubble
checkpointId:…Checkpoint pointers
agentKv:blob:<sha256>Content-addressed JSON blobs

Ordering trap: Cursor stores many bubbleId:* rows (drafts, tool noise). The real thread order is fullConversationHeadersOnly inside composerData — not createdAt on each bubble.

Safety before you export

  1. Quit Cursor or expect occasional database is locked (WAL mode).
  2. Open DBs read-onlyfile:…?mode=ro URI.
  3. Redact JWTs, sk-… keys, and tokens before sharing exports.
  4. Do not wire cursorAuth/accessToken into scripts that send data off-machine.

Export script

Save as cursor_export.py. Requires Python 3.10+ and no third-party packages.

#!/usr/bin/env python3
"""Export Cursor Composer chat history from local state.vscdb (read-only)."""
from __future__ import annotations

import argparse
import json
import re
import sqlite3
import sys
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator, Optional

JWT_RE = re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{8,}\b")
SECRET_RES = [re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b"), re.compile(r"\bghp_[A-Za-z0-9]{20,}\b")]


def cursor_paths() -> dict[str, Path]:
    home = Path.home()
    if sys.platform == "darwin":
        base = home / "Library/Application Support/Cursor"
    elif sys.platform == "win32":
        base = home / "AppData/Roaming/Cursor"
    else:
        base = home / ".config/Cursor"
    return {
        "global_db": base / "User/globalStorage/state.vscdb",
        "workspace_root": base / "User/workspaceStorage",
        "plans": home / ".cursor/plans",
    }


def redact(text: str) -> str:
    if not text:
        return text
    text = JWT_RE.sub("<REDACTED_JWT>", text)
    for pat in SECRET_RES:
        text = pat.sub("<REDACTED_SECRET>", text)
    return text


@contextmanager
def ro_sqlite(path: Path) -> Iterator[sqlite3.Connection]:
    con = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5.0)
    try:
        yield con
    finally:
        con.close()


def _parse_json(val: Any) -> Any:
    if isinstance(val, (bytes, bytearray)):
        val = val.decode("utf-8", errors="replace")
    return json.loads(val)


def list_composers(gdb: Path, ws_root: Path) -> list[dict]:
    """Primary: composer.composerHeaders. Fallback: per-workspace composer.composerData."""
    by_id: dict[str, dict] = {}
    if gdb.exists():
        with ro_sqlite(gdb) as con:
            row = con.execute(
                "SELECT value FROM ItemTable WHERE key = 'composer.composerHeaders'"
            ).fetchone()
            if row:
                for c in _parse_json(row[0]).get("allComposers") or []:
                    cid = c.get("composerId")
                    if cid:
                        by_id[cid] = {
                            "composerId": cid,
                            "name": redact(str(c.get("name") or ""))[:200],
                            "lastUpdatedAt": c.get("lastUpdatedAt"),
                        }
    # Older builds: workspace ItemTable composer.composerData
    if ws_root.exists():
        for ws in ws_root.iterdir():
            ws_db = ws / "state.vscdb"
            if not ws_db.exists():
                continue
            try:
                with ro_sqlite(ws_db) as con:
                    row = con.execute(
                        "SELECT value FROM ItemTable WHERE key = 'composer.composerData'"
                    ).fetchone()
                if not row:
                    continue
                for c in _parse_json(row[0]).get("allComposers") or []:
                    cid = c.get("composerId")
                    if cid and cid not in by_id:
                        by_id[cid] = {
                            "composerId": cid,
                            "name": redact(str(c.get("name") or ""))[:200],
                            "lastUpdatedAt": c.get("lastUpdatedAt"),
                        }
            except Exception:
                continue
    return sorted(by_id.values(), key=lambda x: x.get("lastUpdatedAt") or 0, reverse=True)


def load_conversation(gdb: Path, composer_id: str, max_chars: int = 8000) -> dict:
    with ro_sqlite(gdb) as con:
        meta_row = con.execute(
            "SELECT value FROM cursorDiskKV WHERE key = ?",
            (f"composerData:{composer_id}",),
        ).fetchone()
        meta = _parse_json(meta_row[0]) if meta_row else {}
        headers = meta.get("fullConversationHeadersOnly") or []
        order = {h["bubbleId"]: i for i, h in enumerate(headers) if h.get("bubbleId")}
        restrict = bool(order)
        rows = con.execute(
            "SELECT key, value FROM cursorDiskKV WHERE key LIKE ?",
            (f"bubbleId:{composer_id}:%",),
        ).fetchall()

    bubbles = []
    for idx, (key, val) in enumerate(rows):
        d = _parse_json(val)
        bid = key.rsplit(":", 1)[-1]
        if restrict and bid not in order:
            continue
        role = "user" if d.get("type") == 1 else "assistant" if d.get("type") == 2 else "other"
        text = d.get("text") or d.get("richText") or ""
        if isinstance(text, list):
            text = " ".join(str(x.get("text", "")) for x in text if isinstance(x, dict))
        sort = (0, order[bid]) if restrict else (1, idx)
        bubbles.append((sort, {"role": role, "text": redact(str(text))[:max_chars]}))
    bubbles.sort(key=lambda t: t[0])
    return {"composerId": composer_id, "name": meta.get("name"), "bubbles": [b for _, b in bubbles]}


def to_markdown(conv: dict) -> str:
    lines = [f"# {conv.get('name') or conv['composerId']}", ""]
    for b in conv["bubbles"]:
        label = "User" if b["role"] == "user" else "Assistant"
        lines += [f"## {label}", "", b["text"], ""]
    return "\n".join(lines)


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--out", type=Path, default=Path("cursor-export"))
    ap.add_argument("--list", action="store_true")
    ap.add_argument("--composer-id")
    ap.add_argument("--max-chars", type=int, default=8000)
    args = ap.parse_args()
    paths = cursor_paths()
    gdb = paths["global_db"]
    if not gdb.exists():
        sys.exit(f"Not found: {gdb}")
    composers = list_composers(gdb, paths["workspace_root"])
    if args.list:
        for c in composers:
            print(c["composerId"], c.get("name"))
        return
    args.out.mkdir(parents=True, exist_ok=True)
    (args.out / "chats").mkdir(exist_ok=True)
    (args.out / "composers.json").write_text(json.dumps(composers, indent=2))
    targets = [c for c in composers if c["composerId"] == args.composer_id] if args.composer_id else composers
    for c in targets:
        conv = load_conversation(gdb, c["composerId"], args.max_chars)
        cid = c["composerId"]
        (args.out / "chats" / f"{cid}.json").write_text(json.dumps(conv, indent=2))
        (args.out / "chats" / f"{cid}.md").write_text(to_markdown(conv))
    plans = paths["plans"]
    if plans.exists():
        dest = args.out / "plans"
        dest.mkdir(exist_ok=True)
        for p in plans.glob("*.plan.md"):
            dest.joinpath(p.name).write_text(p.read_text(errors="replace"))
    print(f"Done → {args.out.resolve()}")


if __name__ == "__main__":
    main()

Usage

python3 cursor_export.py --list
python3 cursor_export.py --out ./cursor-export
python3 cursor_export.py --composer-id <uuid> --out ./one-chat
python3 cursor_export.py --max-chars 4000 --out ./cursor-export

Plans and agent transcripts

Output layout

cursor-export/
  composers.json          # index of all threads
  chats/
    <composerId>.json     # structured bubbles
    <composerId>.md       # human-readable transcript
  plans/
    *.plan.md

Troubleshooting

IssueFix
database is lockedQuit Cursor; retry read-only open
Shuffled messagesFilter bubbles via fullConversationHeadersOnly
Empty composer listScript falls back to workspace composer.composerData
Export too largeLower --max-chars; export one --composer-id

FAQ

Is this official?

No. It reads local files Cursor already writes. Schema can change between Cursor versions.

Does it need my Cursor login?

No cloud calls. Everything is parsed from disk.

Can I export on Windows or Linux?

Yes — cursor_paths() adjusts the base directory per OS.

Your chats are already on disk — export them before you switch tools.