Skip to content

letta

Letta (formerly MemGPT) has two codebases that matter on a host. Letta Code, the current letta CLI, keeps its state in ~/.letta and its conversations on the Letta server by default, but writes two parseable JSONL stores under ~/.letta and, in local-backend mode, the full message history in pi's session format into its own directory, which the pi parser's reader reads. The retired Letta V1 Python server (and MemGPT before it) kept messages in SQLite. Catalog agent: letta. Parsed: the letta parser reads the reflection transcripts, the local-backend messages.jsonl files, sessions.jsonl, the sessionsByServer and permissions settings, the channel routing, the scheduled tasks and their run logs, and the interrupted listener turns; the V1 sqlite.db, the input ledger and the chunk logs are not parsed.

Source and evidence

Both codebases are Apache-2.0 and open source:

The message types used by the Letta Code local backend come from @earendil-works/pi-ai (package.json:151), read in earendil-works/pi at 200387122ca450d6387f033949423114a270b96c (letta-code pins ^0.99.1; the pi checkout is newer, so optional fields may differ). All claims are from source.

Observed

  • No real install. No real install has shown the files behind the session synced: rows, so those rows are fixture only (Sync, relay and origin).

Source reviews

  • 2026-10-21: letta-ai/letta-code at 2a46615 (0.34.17), with the Letta client SDK letta-ai/letta-node at fd801c6 (v1.10.2, the version letta-code pins): corrected sessions.jsonl to exit lines only, added the shape of an interrupted turn's results, the scheduled-task prompt wrapper and the legacy routing.yaml, and moved the permission settings, channel routing, scheduled tasks, run logs and interrupted turns from not read to parsed in the Parser plan; added Source host rows.
  • 2026-10-19: letta-ai/letta-code at 2a46615, the head of main (0.34.17, package.json:3), compared file by file with 77faf36 (0.34.2). The review added the permission settings, the scheduled-task run logs, the input ledger, channel routing and the OpenCode external agent, and recorded in Format versions that the shapes the parser reads are unchanged.

Collection

Per-user storage

Letta Code

Letta Code resolves everything from os.homedir() plus .letta, on every OS: ~/.letta on Linux and macOS, %USERPROFILE%\.letta on Windows (settings-manager.ts:976-980). Some modules prefer $HOME over os.homedir() (cron-file.ts:136-139). A legacy settings path is $XDG_CONFIG_HOME/letta/settings.json, falling back to ~/.config/letta/settings.json on every OS, Windows included (permissions/loader.ts:48-62).

Under ~/.letta:

With the default API backend, agents and messages live on the server (Letta Cloud or a self-hosted server); the local evidence is transcripts/, sessions.jsonl, the logs and the memory clones.

Scratch directories

Letta Code works in the user's cwd. Its own working copies are the memory clones agents/<agent id>/memory/ and their memory-worktrees/, inside ~/.letta, so the catalog collects them and the analyzer classes a path there as a scratch directory, as it does projects/<sanitised cwd>/agent-tools/. Its external coding agents (Claude Code, Codex and, since 77faf36, OpenCode, started as opencode serve in the working directory; src/agent/subagents/index.ts:123-159, src/tools/impl/opencode-server.ts:51-66) run in the same cwd and write to their own directories, which their own catalog lines collect.

Letta V1 server

The Letta V1 server used ~/.letta too (LETTA_DIR, archive constants.py:5-6): config (config.py:42), logs/Letta.log (log.py:188), tool_execution_dir/, and pg_uri for Letta Desktop (settings.py:258-269). Up to at least 0.11.7 the default database was ~/.letta/sqlite.db (0.11.7 db.py:149, 194); at the archive head the engine is always Postgres, defaulting to localhost:5432 (settings.py:472-478, db.py:21). 0.4–0.5 kept ~/.letta/credentials (0.4.1 credentials.py:15) and ~/.letta/chroma (0.4.1 config.py:67-68).

MemGPT

MemGPT (≤0.3.x) used ~/.memgpt (0.3.25 constants.py:4) with credentials, config, sqlite.db and chroma/ (credentials.py:15, config.py:61-72, metadata.py:342-343). No Letta release reads ~/.memgpt; a grep of 0.4.1, 0.11.7 and the archive head finds no reference.

What a delete removes and leaves is in What survives a delete.

Credentials

Exclusions

.letta/bin (ripgrep and fd binaries), .letta/mod-cache. V1: .letta/tool_execution_dir (tool sandboxes and virtualenvs), .letta/chroma, .memgpt/chroma (embedding stores). Keep agents/*/memory/.git: it carries the memory edit history and the credential helper.

Project-local files

<project>/.letta/settings.json (checked-in hooks and window title) and .letta/settings.local.json (permissions, sessionsByServer, last agent) (settings-manager.ts:982-997, 143-158, permissions/loader.ts:70-71). Project skills are read from .agents/skills and the legacy .skills (skills.ts:181-186). No per-project database.

Both project files, like the user files ~/.letta/settings.json and ~/.config/letta/settings.json, can hold a permissions object with the standing rules (Permission settings).

Where the project path is recorded

  • ~/.letta/sessions.jsonl key project, which is process.cwd() (AppCoordinator.tsx:403, 3841-3851). Grep-able: "project":"<path>".
  • ~/.letta/lc-local-backend/conversations/*/messages.jsonl first line, key cwd (local-transcript.ts:236-246).
  • ~/.letta/projects/<dir>/: the cwd with the leading separator dropped and /, \, : and whitespace turned into _ (lossy).
  • listener-state/*/*.json workingDirectory names a working directory (Interrupted turns), and additionalDirectories in a permissions object names directories the user let the agent reach (Permission settings).

Parsing

Transcript stores

Glob (relative to home) One file per Mode
.letta/transcripts/*/*/transcript.jsonl agent and conversation every turn, both backends
.letta/lc-local-backend/conversations/*/messages.jsonl conversation local backend only
.letta/sessions.jsonl user (one line per session ended with /exit or /logout) always
.letta/logs/chunk-logs/*/*.jsonl agent and CLI session, last 5 kept always, truncated
.letta/sqlite.db user V1 server up to at least 0.11.7

transcripts/<agent>/<conversation>/ names are the ids with anything outside [a-zA-Z0-9._-] replaced by _ (reflection-transcript.ts:578-581, 911-925); state.json and payload-*.json sit beside it. Local-backend conversation directories are base64url of conversation:<id>, or default:<agent id> for an agent's default conversation (local-store.ts:283-285, 3262-3266), next to conversation.json, manifest.json and system-prompt.json.

Server-side session markers

In the default backend, api (Letta Cloud at https://api.letta.com, or a self-hosted Letta server named by LETTA_BASE_URL), the agent and its conversations are server objects and the local files name them by id (settings-manager.ts:75, 195, 273-281; cli/subcommands/backend.ts:38). A local-backend agent id starts agent-local-; any other agent id is a server id (agent/agent-id.ts:3-9).

  • .letta/transcripts/<agent id>/<conversation id>/: the two directory names are the server's ids, sanitised as above.
  • .letta/sessions.jsonl agent_id: the server agent id; its session_id is the CLI's own telemetry session id, not a conversation (cli/app/AppCoordinator.tsx:3841-3844).
  • .letta/settings.json sessionsByServer: an object keyed by the server's host and port (api.letta.com, localhost:8283, or local:<store path> for the local backend) whose values are {agentId, conversationId}, the last session used on that server; lastSession and lastAgent repeat it for server agents (settings-manager.ts:53-56, 94, 215-225, 1231-1274). The project's .letta/settings.local.json has the same keys for that directory (157, 995-996, 1365-1405). Both are rewritten on every start, resume and conversation switch through persistSession (1447-1455; index.ts:2356), so they hold one session per server, never a list.
  • .letta/settings.json agents[]: per-agent settings with agentId and baseUrl, absent for Letta Cloud, and memfs when the memory filesystem is on (agent-settings.ts:7-19). deviceId identifies the install to the server (settings-manager.ts:100).
  • .letta/agents/<agent id>/memory/.git/config: the memory filesystem ("memfs", the agent's memory blocks kept as files in a git repository) has remote origin at <server>/v1/git/<agent id>/state.git and is pushed there (agent/memory-git.ts:1-3, 89-96, 228-233).
  • .letta/settings.local.json listenerEnvName: the name under which letta server or /listen registered this machine as a remote environment with Letta Cloud (/v1/environments/register), so the web app can drive local sessions (settings-manager.ts:158; websocket/listen-register.ts:110-128).

What survives a delete

Deleting a server agent from the /agents selector deletes it on its server and touches no local file (cli/components/AgentSelector.tsx:486-514), so its transcripts/ directories, its sessions.jsonl lines and the settings entries that name it all remain, and nothing local records that the server copy is gone. Deleting a local-backend agent removes its conversation directories under lc-local-backend/ (with messages.jsonl) and leaves ~/.letta/transcripts/ alone (local-store.ts:481-517; 483-521 at 2a46615). The only callers of deleteConversation remove internal fork and ephemeral conversations (agent/subagents/fork-conversation.ts:100, websocket/app-server-openai.ts:326). Letta Code keeps no per-prompt history file. Since a server-agent delete touches no local file, the chunk logs (the last 5 per agent and session, content cut to 200 characters), the interrupted-turn records under listener-state/, the input ledger under state/ and the scheduled-task run logs also remain after it; what a local-backend delete does to them is not stated. The analyzer reads transcript.jsonl as a secondary source (see Secondary sources), so a transcript whose messages.jsonl was deleted keeps all its rows, and reads sessions.jsonl, crons.json, the run logs, the interrupted-turn records and the channel routing as primary sources; it does not read the input ledger or parse the chunk logs into turns (Past use and secondary sources).

Record schema

Reflection transcript

transcript.jsonl, appended after each completed turn (use-conversation-loop.ts:1443-1452, reflection-transcript.ts:927-952). One object per line (reflection-transcript.ts:54-71): kind in user|assistant|reasoning|error with text; or kind:"tool_call" with name, argsText, resultText, resultOk. Every line has captured_at (ISO 8601 UTC string, the same value for all lines of one turn batch) and optional source_line_id, source_message_id. No model, no cwd, no tool call id: a call and its result share one line.

captured_at is the time of the append, not of the event: appendTranscriptDeltaJsonl takes new Date().toISOString() once and stamps every line of the batch with it (reflection-transcript.ts:927-946). Its three callers append a turn's lines only after the turn ends with the end_turn stop reason: the interactive loop (use-conversation-loop.ts:1423-1452), headless mode when the result is a success (headless.ts:4650-4652) and the WebSocket listener's completeSuccessfulListenerTurn (turn-completion.ts:67-73). So a user prompt is stamped later than its submission by the whole turn, tool calls and approval waits included, and a turn that ends in an error or an interrupt is not appended at all. argsText is the tool call's arguments string as streamed, the chunks concatenated (accumulator.ts:1094, 1150-1153), so a complete call holds the arguments JSON.

Local-backend transcript

messages.jsonl (message_format pi-session-entry-jsonl, schema_version 2 in manifest.json; local-transcript.ts:24-46). Line 1 is the header {type:"session", version:3, id, timestamp, cwd}; later lines are {type:"message", id, parentId, timestamp, message} or {type:"compaction", id, parentId, timestamp, summary, firstKeptEntryId, tokensBefore, message, details} (local-transcript.ts:122-157, 236-246, local-store.ts:3036-3061). Entry id is 8 hex characters; entry timestamp is ISO, taken from message.metadata.created_at or message.timestamp (local-transcript.ts:479-494).

message (local-message.ts:26-86) has id (prefix letta-msg-, local-store.ts:135-137), role, timestamp (Unix ms), optional metadata {created_at, agent_id, conversation_id, provider{provider_id,model_id,usage}}, and:

  • role:"user": content string or array of text/image parts (pi types.ts:542-546).
  • role:"assistant": content array of {type:"text",text}, {type:"thinking",thinking,thinkingSignature?,redacted?}, {type:"toolCall",id,name,arguments}; plus api, provider, model, usage, stopReason (types.ts:397-427, 548-571).
  • role:"toolResult": toolCallId, toolName, content (text/image), isError (types.ts:596-609). A denied approval is written as an error toolResult whose text is the user's reason or Tool execution denied. (src/backend/local/local-store.ts:1509-1549).

sessions.jsonl

.letta/sessions.jsonl (session-history.ts:6-35): agent_id, session_id, timestamp (ms), project, model, provider, usage, duration, cost, message_count, tool_call_count, exit_reason (48-58, 116-158). Session metadata, not turns. Only recordSessionEnd writes the file: recordSessionStart (79-109) has no caller at 77faf36 or 2a46615, and recordSessionEnd is called from the /exit handler with exit_reason exit_command (cli/app/AppCoordinator.tsx:3838-3855) and from /logout with logout (cli/app/use-submit-handler.ts:1625-1642). So the file holds one line per CLI session that ended with /exit or /logout, stamped with the time of exit; a session still running, or one that ended any other way (a crash, a killed process), has no line. session_id is the telemetry session id and project the process's working directory.

Joins and timestamps

  • Reflection transcript: agent and conversation from the two directory names. Project path: the sessions.jsonl line with the same agent_id whose timestamp is the earliest at or after captured_at, which is the exit line of the CLI session the turn belongs to when that session ended with /exit or /logout; when no line is later, the latest line before it (heuristic; a session id is not stored in the transcript, and a session with no exit line is attributed to the next session's project).
  • Local backend: session id is the header id (conversation id); project path is the header cwd; conversation.json gives agent_id (local-types.ts:30-43) and lc-local-backend/agents/*.json the agent name, model, system (local-types.ts:18-28). toolCall.id joins toolResult.toolCallId. Entries chain by parentId.
  • Subagents: conversation.json parent_agent_id, is_subagent.
  • Transcript to local backend, for one conversation. Session id: the transcript's conversation directory is the conversation id sanitised to [a-zA-Z0-9._-], and the messages.jsonl header id is the same conversation id (local-transcript.ts:236-246). Local-backend ids are local-conv-<n> or local-conv-<uuid> (local-backend.ts:209, local-store.ts:3177-3181, 762), or default for the agent's default conversation (local-store.ts:3262-3266), none of which the sanitisation changes, so the two session_ids are equal. Project path: the header cwd against the sessions.jsonl heuristic above; equal in the fixture, but the pairing is by session id and does not depend on it. Text: the same for user and assistant text, thinking and tool results; a tool call's argsText, parsed, gives the argument the pi reader shows for the matching toolCall. Time: the transcript line is later than its messages.jsonl entry by up to the turn's duration, the prompt by all of it.

Other stores

Letta Code keeps no SQLite. Channel routing, the scheduled tasks, the interrupted turns, the input ledger and the permission settings are cited at 2a46615. Which of these stores the parser reads is in the Parser plan.

V1 server database

V1 ~/.letta/sqlite.db (0.11.7 db.py:149), table messages (orm/message.py:15-61): id, agent_id, role (user|assistant|system|tool), text, content (JSON parts), model, tool_calls (OpenAI-shaped JSON), tool_call_id, tool_returns, step_id, sequence_id, created_at (orm/base.py:16). Proposed as a later parser; the JSON column encodings were not verified.

Chunk logs

The chunk logs, .letta/logs/chunk-logs/*/*.jsonl, hold the last 100 server stream chunks with content cut to 200 characters (chunk-log.ts:1-12, 27-30). Useful for message ids and timing only; not parsed into turns.

Channel routing

~/.letta/channels/<channel>/routing.json: {routes: [...]}, each route accountId, chatId, chatType, threadId, agentId, conversationId, enabled, outboundEnabled, detached, createdAt and updatedAt (ISO 8601): the messaging chat a conversation is relayed through (src/channels/config.ts:35, 58-60, src/channels/types.ts:453-476, src/channels/routing-store.ts:15-17). Joins on conversationId and agentId. The channels' first version named the same JSON file routing.yaml; Letta reads that name only when routing.json is absent, links it to the new name and removes it, so a copy can survive an interrupted migration (src/channels/config.ts:62-69, routing-store.ts:27-73). The built-in channel names are telegram, slack, discord, custom, whatsapp and signal (src/channels/types.ts:72-79), and chatType is direct or channel (87).

Scheduled tasks

~/.letta/crons.json: {version, scheduler_owner, scheduler_owners, tasks}, each task id, agent_id, conversation_id, name, description, cron, timezone, recurring, prompt, status, created_at, last_fired_at, fire_count, cancel_reason, last_run_at, last_run_outcome and more (src/cron/cron-file.ts:62-131); fired, missed and cancelled tasks are dropped after a day. Beside it, runs/<task id>.jsonl, one line per run: ts (ms), jobId, action, status, outcome, reason, error, summary, agentId, conversationId, runId and the schedule window (src/cron/run-log.ts:28-47, 76-83). The task's prompt is the text a scheduled turn sends, wrapped in a header that names the task, its schedule and the current time, ending Prompt: <prompt> (src/cron/scheduled-task-prompt.ts:173-189, src/cron/scheduler.ts:404), so no transcript line has the bare prompt. conversation_id is default, new (a fresh conversation per fire) or a conversation id (cron-file.ts:66-72); status is active, paused, fired, missed or cancelled, last_run_outcome queued, missed, failed or skipped, and cancel_reason conversation_not_found or expired (33-50). A run log line's status is ok, error or skipped and its action finished (run-log.ts:26-31).

Interrupted turns

The file is the following, both ids URL-encoded:

  • ~/.letta/listener-state/<first 24 hex of sha256 of the server URL>/<agent id>_<conversation id>.json

It holds a turn the remote listener did not finish, with agentId, conversationId, runId, toolCallIds, unstartedToolCallIds, results (the approval results), workingDirectory and actingUserId (src/websocket/listener/interrupted-turn-storage.ts:51-56, interrupted-turn-types.ts:5-59). The transcripts never hold an interrupted turn, so this is its only local trace. Each results entry is one of the Letta client SDK's approval payloads (src/agent/approval-execution.ts:137-142): an ApprovalReturn, {type: "approval", approve, tool_call_id, reason}, the user's decision on a call (letta-node src/resources/agents/messages.ts:319-339), or a ToolReturn, {type: "tool", status, tool_call_id, tool_return, stdout, stderr} with status success or error, the result of a call that ran (2208-2226), which letta-code may extend with the approval's reason. The record has no time of its own. The .json.recovery-<24 hex> sidecars and .tmp files beside it are recovery bookkeeping (recovery-lineage-sidecar.ts:60-77, interrupted-turn-record.ts:52-53).

Input ledger

~/.letta/state/<24 hex>/input-dispositions-v2.json, new since 77faf36: the listener's ledger of accepted remote inputs, each entry with acceptedAt and expiresAt and, until it has been replayed after a crash, the queuedInput itself (src/websocket/listener/input-disposition-path.ts:5-17, types.ts:317-340). What queuedInput holds was not read further.

Permission settings

The standing permission rules are a permissions object, {mode, allow, deny, ask, alwaysAsk, additionalDirectories}, the rules being strings in Claude Code's form (Bash(npm test), Edit(**)) (src/permissions/types.ts:9-21), merged from ~/.config/letta/settings.json, ~/.letta/settings.json and a project's .letta/settings.json and .letta/settings.local.json (src/permissions/loader.ts:59-72). mode is standard, acceptEdits, unrestricted (the default) or strict; the legacy default, bypassPermissions and fullAccess map onto them (src/permissions/mode.ts:3-35). /approve-always appends an allow rule to the scope the user picks (src/cli/app/use-approval-flow.ts:745-756, loader.ts:329-345).

Format versions

Local backend: schema 1 (pi-ai-message-jsonl, bare message per line) and schema 2 (session entries); letta local-backend migrate-transcripts converts 1 to 2 and leaves messages.jsonl.pre-pi-backup-* (local-transcript.ts:24-81). Messages may be re-appended with the same message.id as replacement snapshots (local-transcript.ts:173-182); keep the last. Reflection state.json schema_version is v3_assistant_steps (reflection-transcript.ts:28-38).

Between 77faf36 (0.34.2) and 2a46615 (0.34.17) the shapes the parser reads did not change: src/backend/local/local-message.ts, src/cli/helpers/reflection-transcript.ts, src/agent/session-history.ts, src/utils/transcript-paths.ts, src/utils/local-backend-paths.ts and src/settings-manager.ts are byte for byte the same, local-transcript.ts changed only how it reads the file, and the model fields of all three stores are unchanged. The interrupted-turn code moved from interrupted-turn-record.ts into several files, among them src/websocket/listener/interrupted-turn-storage.ts, and the channel gateway modules were removed.

Secrets

thinkingSignature and textSignature are opaque provider blobs. Tool arguments and results can hold pasted keys. V1 sqlite.db table providers column api_key (0.11.7 orm/provider.py:30-32).

Parser plan

Parse transcript.jsonl and messages.jsonl always (issue 122), and sessions.jsonl, the settings files, channels/*/routing.json, crons.json, runs/*.jsonl and the listener-state/*/*.json records.

Column transcript.jsonl messages.jsonl
timestamp_utc captured_at entry timestamp
session_id conversation directory name header id
project_path sessions.jsonl project (heuristic) header cwd
git_branch empty empty
turn_type user, assistant; reasoning → thinking; error → system; tool_call → one tool_use and one tool_result row role/part type; compaction → system
model empty message.model (responseModel when set)
tool_name, tool_use_id name, empty toolCall.name/id, toolResult.toolName/toolCallId
text text; argsText through pi's argument summary when it parses as JSON; resultText text, thinking, the argument summary, result text

Each sessions.jsonl line is one system row, letta session end: agent=<agent_id> exit_reason=<exit_reason> messages=<message_count> tool_calls=<tool_call_count> wall_ms=<duration.wall_ms>, at its timestamp, with its session_id and project; a line without exit_reason or message_count, which no caller writes (sessions.jsonl), would read letta session start: agent=<agent_id> provider=<provider> model=<model> project=<project>.

Permission rows

None of the transcript shapes in Record schema records a permission decision. A denied call in messages.jsonl is an error toolResult (Local-backend transcript), shown as a plain tool_result.

The parser reads the standing rules, source-derived; not observed on any real install: the permissions object (Permission settings) of ~/.letta/settings.json, the legacy ~/.config/letta/settings.json and a project's .letta/settings.json and .letta/settings.local.json, one system row per entry, permission setting: permissions.<key>=<value> (from <file>, file mtime), the form the claude-code parser uses: one per rule of allow, deny, ask and alwaysAsk, one per additionalDirectories entry and one for mode, printed as recorded (a legacy value is not mapped). The row is at the file's modification time, with an empty session_id and, for a project file, the project as project_path. <file> is the path relative to the home or the project.

The interrupted-turn records (Interrupted turns) carry the decisions of a turn the listener did not finish: each ApprovalReturn in results is a system row permission decision: <approve|deny> reason=<reason> (from <file>, interrupted turn, file mtime) with the tool_call_id as tool_use_id, source-derived; not observed on any real install (see Past-use rows and secondary sources).

Model rows

messages.jsonl goes through pi's reader, so it gets pi's model: <id> provider=<provider> row at the first assistant message.model of a conversation and model: <old> -> <new> provider=<provider> on each change (Model in pi.md). After the first sessions.jsonl line of a session_id that names a model, a model: <model> provider=<provider> row follows, and a later line of the same session_id with another model gets a model: <old> -> <new> provider=<provider> row; since a session has at most one line (sessions.jsonl), the change row does not occur in practice. transcript.jsonl, the scheduled tasks and the interrupted turns record no model.

Past-use rows and secondary sources

In local-backend mode messages.jsonl is the store of record, the local backend's own message store, with models, tool ids and per-message times; it is primary. transcript.jsonl copies each completed turn of the same conversation and is a secondary source: its rows are dropped when a messages.jsonl row of the same conversation has the same turn type and text within one hour, the window a turn's end-of-turn stamp needs (see Record schema); a turn longer than an hour leaves its earlier lines shown twice. What only the transcript keeps survives the rule: deleting a local-backend agent leaves ~/.letta/transcripts/ alone (What survives a delete), and in the default server-backend mode the transcript is the only local copy of the conversation. messages.jsonl alone holds the turns that ended in an error or an interrupt. sessions.jsonl is primary, one system row per line, and gives the transcript's project attribution (Joins and timestamps).

The scheduled tasks, their runs and the interrupted turns are read as primary sources, source-derived; not observed on any real install. None repeats a transcript event: a scheduled turn's message wraps the prompt in a header (Scheduled tasks), and an interrupted turn is never appended to a transcript. - stands for an absent value.

  • crons.json: one system row per task, scheduled task: <id> name=<name> status=<status> cron=<cron> timezone=<timezone> recurring=<recurring> scheduled_for=<UTC> agent=<agent_id> conversation=<conversation_id> fire_count=<fire_count> last_fired=<UTC> last_run=<UTC> outcome=<last_run_outcome> reason=<last_run_reason> error=<last_run_error> cancel_reason=<cancel_reason> (from .letta/crons.json, created_at) description: <description> prompt: <prompt>, at created_at (the file's modification time, ending file mtime), when it has none), with conversation_id as session_id, empty for new. A file that is not a JSON object is one parser: row. Fired, missed and cancelled tasks leave the file after a day, so the run logs outlast them.
  • runs/<task id>.jsonl: one system row per line, scheduled task run: <jobId> action=<action> status=<status> outcome=<outcome> reason=<reason> agent=<agentId> conversation=<conversationId> run=<runId> scheduled_for=<UTC> fired=<UTC> missed=<missedCount> summary=<summary> error=<error> (from <file>, ts), at ts, with conversationId as session_id.
  • listener-state/<24 hex>/<agent id>_<conversation id>.json: one system row interrupted turn: agent <agentId> conversation <conversationId> run <runId> tool_calls=<toolCallIds> unstarted=<unstartedToolCallIds> results=<n> working_directory=<workingDirectory> acting_user=<actingUserId> (from <file>, file mtime) (ids comma-separated), then per results entry the permission decision: row of Permission rows for an ApprovalReturn, or a tool_result row for a ToolReturn whose text is tool_return ([error]  before it when status is error), with the tool_call_id as tool_use_id. All are at the file's modification time, since the record has none, with conversationId as session_id and workingDirectory as project_path. The .json.recovery-* sidecars and .tmp files are not read. A file that is not a JSON object is one parser: row.

The input ledger is not read (what queuedInput holds was not reviewed), and the chunk logs are not parsed into turns (Other stores).

Sync, relay and origin rows

In the default api backend the conversation is server state by design, not an upload: Letta Code creates, reads and deletes the agent and its conversations through the server's API (backend/backend.ts:376, 481-524), the server keeps every message, and the Letta Chat web app opens the same conversation at https://chat.letta.com/chat/<agent id>?conversation=<conversation id> (cli/helpers/app-urls.ts:14-37). It is on unless the user ran letta backend local, which sets preferredBackendMode to local in .letta/settings.json (cli/subcommands/backend.ts:38, 65). The local markers are the ids listed under Server-side session markers. The parser yields, fixture only (no real install has shown these files):

  • per transcripts/<agent id>/<conversation id>/ whose agent id does not start agent-local-, one system row session synced: server conversation <conversation id> agent <agent id> server <server> (from .letta/transcripts path, first captured_at), at the first captured_at of its transcript.jsonl, with <server> the matching agents[] entry's baseUrl, api.letta.com when that entry has none, and - when there is no entry;
  • per sessionsByServer entry whose key does not start local:, in .letta/settings.json and in each project's .letta/settings.local.json, one system row session synced: last session on <key> agent <agentId> conversation <conversationId> (from <file>, file mtime), at the file's modification time, with session_id the conversation id and, for the project file, project_path the project.

Memfs pushes the agent's memory to the server's git endpoint (see Server-side session markers); a commit there is memory, not a transcript, and is not a row. Telemetry is on unless LETTA_CODE_TELEM is 0 or false or DO_NOT_TRACK is 1, goes to /v1/metadata/telemetry, and carries counts, tool names and lengths, not prompt text; error events, sent for Letta Cloud users only, add recent_chunks and debug_log_tail, which can hold fragments of streamed output; the queue is in memory and leaves no file (telemetry/index.ts:37-112, 392-405, 776-800, 478-480; backend/api/metadata.ts:100). /feedback sends the user's message with the agent id, telemetry session id and last run id, and no transcript (cli/app/use-feedback-handler.ts:78-94).

Relay is recorded in channels/<channel>/routing.json, or a leftover legacy routing.yaml holding the same JSON (Channel routing). The parser reads both, source-derived; not observed on any real install, sets records_relay, and yields per route one system row session relayed: <channel> chat <chatId> type <chatType> thread <threadId> account <accountId> agent <agentId> enabled=<enabled> outbound=<outboundEnabled> detached=<detached> updated=<updatedAt> (from <file>, createdAt), at createdAt (the file's modification time, ending file mtime), when it has none), with conversationId as session_id, <channel> the directory name and - for an absent value. A file without a routes list is one parser: row. accounts.json beside it holds the bot tokens (Credentials) and is not read. No remote-origin listing found.

Scratch directory rows

Classed by the catalog rule: the memory clones and their memory-worktrees/ under ~/.letta/agents/<agent id>/ and ~/.letta/projects/<sanitised cwd>/agent-tools/ sit under the .letta entry (Scratch directories); none outside the home.

Deletion rows

Covered: no local file records a server-side delete (What survives a delete).

Bad-line rows

Covered for the files read: each undecodable line of transcript.jsonl, messages.jsonl, sessions.jsonl and runs/*.jsonl is its own parser: row, and a settings, routing, crons.json or interrupted-turn file that is not JSON is one row for the file.

Fixtures

  • .letta/transcripts/agent-1a2b/conv-9f/transcript.jsonl:
{"kind":"user","text":"list the repo","captured_at":"2026-10-01T12:00:05.120Z","source_line_id":"ui-1"}
{"kind":"tool_call","name":"Bash","argsText":"{\"command\":\"ls\"}","resultText":"README.md","resultOk":true,"captured_at":"2026-10-01T12:00:05.120Z","source_line_id":"ui-2"}
{"kind":"assistant","text":"One file: README.md","captured_at":"2026-10-01T12:00:05.120Z","source_line_id":"ui-3","source_message_id":"message-77"}
  • .letta/lc-local-backend/conversations/Y29udmVyc2F0aW9uOmxvY2FsLWNvbnYtMQ/messages.jsonl:
{"type":"session","version":3,"id":"local-conv-1","timestamp":"2026-10-01T12:00:00.000Z","cwd":"/home/alice/repo"}
{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-10-01T12:00:01.000Z","message":{"id":"letta-msg-1","role":"user","content":"list the repo","timestamp":1759320001000}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-10-01T12:00:02.000Z","message":{"id":"letta-msg-2","role":"assistant","content":[{"type":"thinking","thinking":"use ls"},{"type":"toolCall","id":"call_1","name":"Bash","arguments":{"command":"ls"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":10,"output":5,"cacheRead":0,"cacheWrite":0,"totalTokens":15,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1759320002000}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-10-01T12:00:03.000Z","message":{"id":"letta-msg-3","role":"toolResult","toolCallId":"call_1","toolName":"Bash","content":[{"type":"text","text":"README.md"}],"isError":false,"timestamp":1759320003000}}
  • .letta/sessions.jsonl, the exit line /exit writes:
{"agent_id":"agent-1a2b","session_id":"s-1","timestamp":1759320060000,"project":"/home/alice/repo","model":"claude-sonnet-4-5","provider":"anthropic","usage":{"prompt_tokens":0,"completion_tokens":0,"total_tokens":0,"cached_input_tokens":0,"cache_write_tokens":0,"reasoning_tokens":0,"steps":0},"duration":{"api_ms":900,"wall_ms":60000},"cost":{"type":"hosted"},"message_count":4,"tool_call_count":1,"exit_reason":"exit_command"}
  • .config/letta/settings.json (or any of the four settings files), the permissions object:
{"permissions":{"mode":"acceptEdits","allow":["Bash(npm test)"],"deny":["Bash(rm -rf:*)"],"additionalDirectories":["/srv/shared"]}}
  • .letta/channels/telegram/routing.json:
{"routes":[{"accountId":"bot-1","chatId":"123456789","chatType":"direct","threadId":null,"agentId":"agent-1a2b","conversationId":"conv-9f","enabled":true,"outboundEnabled":true,"createdAt":"2026-10-01T12:20:00.000Z","updatedAt":"2026-10-01T12:21:00.000Z"}]}
  • .letta/crons.json (one task, abridged to the keys the parser reads) and .letta/runs/a1b2c3d4.jsonl:
{"version":1,"scheduler_owner":null,"scheduler_owners":{},"tasks":[{"id":"a1b2c3d4","agent_id":"agent-1a2b","conversation_id":"conv-9f","name":"nightly tests","description":"run the suite","cron":"0 2 * * *","timezone":"UTC","recurring":true,"prompt":"run the test suite and report failures","status":"active","created_at":"2026-10-01T12:10:00.000Z","last_fired_at":"2026-10-02T02:00:00.000Z","fire_count":1,"cancel_reason":null,"last_run_at":"2026-10-02T02:00:00.000Z","last_run_outcome":"queued","last_run_reason":"scheduled_time_matched","last_run_error":null,"scheduled_for":null}]}
{"ts":1790906400000,"jobId":"a1b2c3d4","action":"finished","status":"ok","outcome":"queued","reason":"scheduled_time_matched","summary":"nightly tests","agentId":"agent-1a2b","conversationId":"conv-9f","runId":"run-1","firedAt":"2026-10-02T02:00:00.000Z"}
{"ts":1790992800000,"jobId":"a1b2c3d4","action":"finished","status":"skipped","outcome":"missed","reason":"scheduler_inactive","summary":"missed","agentId":"agent-1a2b","conversationId":"conv-9f","missedCount":1}
  • .letta/listener-state/0123456789abcdef01234567/agent-1a2b_conv-9f.json:
{"agentId":"agent-1a2b","conversationId":"conv-9f","runId":"run-2","toolCallIds":["call_7","call_8"],"unstartedToolCallIds":["call_8"],"results":[{"type":"tool","status":"error","tool_call_id":"call_7","tool_return":"permission denied","stdout":[],"stderr":[]},{"type":"approval","approve":false,"tool_call_id":"call_8","reason":"not on main"}],"requestOtid":"otid-1","workingDirectory":"/srv/proj","actingUserId":"user-1"}

Source host rows

The agent records no source-host integration. A grep of letta-code at 2a46615 for github, gitlab, bitbucket, oauth, installation, repositor, pull request and clone finds the WatchPR tool, which polls a github.com pull request through the user's own locally authenticated gh CLI (src/tools/descriptions/WatchPR.md:11, src/tools/impl/github-pull-request-watch.ts:414) and keeps no token, account or repository list of its own, and GitHub Copilot as a model provider, whose OAuth token (src/types/provider-oauth-config.ts:18) is a model credential in providers/auth.json (Credentials), not a repository grant. The memfs git remote is Letta's own server (Server-side session markers).

Confidence

  • High (source): every Letta Code path, the keychain fallback, the credential helper, the local-backend layout, the line shapes, field names and timestamp units.
  • High (source): V1 ~/.letta/sqlite.db at 0.11.7 and ~/.memgpt at 0.3.25.
  • Medium: pi-ai optional fields at the pinned version, since the pi checkout is newer (Source and evidence); the api value in the fixture is illustrative.
  • Medium: the release in which V1 dropped SQLite (between 0.11.7 and the archive head, not bisected).
  • Not determined: the V1 content and tool_calls JSON encodings (V1 server database).
  • Not determined: whether reflection transcripts are written on Windows when auto-reflection is off (LETTA_ENABLE_WINDOWS_AUTO_REFLECTION, reflection-settings.ts:9, exists, but the append path at the cited loop is unconditional).
  • Not determined: the Letta Desktop app's Electron data directory (not in either repository).
  • Not determined: the contents of workflows/executions and reflection-arena, and whether mods install node_modules.