Export Cursor Chat History from state.vscdb
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.
| Path | What it holds |
|---|---|
…/User/globalStorage/state.vscdb | Global composer index, chat bubbles, blobs |
…/User/workspaceStorage/<hash>/state.vscdb | Per-workspace aiService.prompts history |
~/.cursor/plans/*.plan.md | Plan mode markdown outputs |
~/.cursor/projects/<slug>/agent-transcripts/ | Agent run JSONL logs |
~/.cursor/ai-tracking/ai-code-tracking.db | Conversation 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 —
(key TEXT UNIQUE, value BLOB)— settings, auth metadata, composer headers - cursorDiskKV —
(key TEXT UNIQUE, value BLOB)— chat payloads at scale
ItemTable keys (global)
composer.composerHeaders→ JSON withallComposers[](id, name, dates, workspace)cursorAuth/accessToken→ JWT — never copy into exports or logs
cursorDiskKV key prefixes
| Prefix | Contents |
|---|---|
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
- Quit Cursor or expect occasional
database is locked(WAL mode). - Open DBs read-only —
file:…?mode=roURI. - Redact JWTs,
sk-…keys, and tokens before sharing exports. - Do not wire
cursorAuth/accessTokeninto 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
- Plans — plain markdown at
~/.cursor/plans/; the script copies them toplans/. - Agent transcripts — JSONL under
~/.cursor/projects/; each line is a JSON event withtextfields. - Summaries — optional table
conversation_summariesinai-code-tracking.db(title,tldr,overview).
Output layout
cursor-export/
composers.json # index of all threads
chats/
<composerId>.json # structured bubbles
<composerId>.md # human-readable transcript
plans/
*.plan.md
Troubleshooting
| Issue | Fix |
|---|---|
database is locked | Quit Cursor; retry read-only open |
| Shuffled messages | Filter bubbles via fullConversationHeadersOnly |
| Empty composer list | Script falls back to workspace composer.composerData |
| Export too large | Lower --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.
Related guides
Your chats are already on disk — export them before you switch tools.