Skip to content

openclaw

OpenClaw (catalog agent openclaw, previously named Clawdbot and then Moltbot) is a self-hosted agent whose Gateway (the long-running openclaw gateway server process that owns every session) takes messages from chat channels (Telegram, WhatsApp, Slack, Signal, iMessage and others under extensions/) and runs agents with shell, browser and file tools, keeping per-agent sessions; it ships as a TypeScript CLI and gateway with a Swift macOS app, and can run in Docker. The Control UI (its web front end), the TUI and the mobile and desktop apps are clients of that Gateway, not copies of its store, and OpenClaw has no vendor account. Its transcript record shape (type: "session" header, then message, model_change, compaction and other entries linked by id/parentId) is the tree-structured session format of its agent core; since mid-2026 the records live as rows in a per-agent SQLite database rather than in .jsonl files, which remain only as legacy import sources and archives. Parsed: the openclaw parser reads the agent database, the Gateway's state database and the legacy and archived session files.

Source and evidence

openclaw/openclaw, commit 3b16db73c0b5209aad2ee7c3fb5ddfb12fb208f9 (2026-10-04, package version 2026.9.8, agent schema version 24 per package.json:5-9), MIT, open source, TypeScript with a Swift macOS app. Every claim is source-level unless it cites docs/ or CHANGELOG/ in the same checkout. Session sharing, backup upload and remote execution were checked at the same commit on 2026-10-17.

Observed

  • No real install of OpenClaw has been surveyed; every review was of source only.
  • The bad-line rows of issue 177 (the legacy sessions.json index and JSON lines that are not objects) are not observed on any real install, source-derived (openclaw/openclaw at 146284a).
  • The public-share and backup-upload rows are fixture only (Sync, relay and origin).

Source reviews

Collection

Per-user storage

Home is $OPENCLAW_HOME, else HOME, else USERPROFILE (normalization-core/src/home-dir.ts:30-31,50). The state directory is $OPENCLAW_STATE_DIR, else ~/.openclaw if it exists, else a legacy ~/.clawdbot if that exists, else ~/.openclaw (src/config/state-dir.ts:8-43). A named profile (OPENCLAW_PROFILE=work) uses ~/.openclaw-<profile> (src/cli/profile-utils.ts:26-37); remote worker machines get ~/.openclaw-worker (gateway/worker-environments/project-setup-script.ts:142). There is no platform branch: the same tree is used on Linux, macOS and Windows (%USERPROFILE%\.openclaw). No XDG directories except one env file (Credentials).

Scratch directories

OpenClaw works in directories of its own: the default agent workspace workspace/ and workspace-<agentId>/ (the transcript header cwd defaults to it), managed git worktrees in worktrees/, repositories the Gateway cloned for a project in projects/<16 hex>/<name> (src/projects/project-clone.ts:276-295), sandbox workspaces in sandboxes/ (agents/sandbox/constants.ts:8), and a cloud worker's remote_workspace_dir on another machine. All but the last are under the state directory, so the catalog's .openclaw line collects them (less the worktrees exclusion) and the analyzer classes a path under them as a scratch directory; state/openclaw.sqlite records which is which (projects.source registered|cloned, worktrees.path and owner_kind, Where the project path is recorded). The temp root /tmp/openclaw (else a per-user os.tmpdir() directory, infra/tmp-openclaw-dir.ts:4) is outside the home and holds the rolling logs and Logbook's capture directory on the capturing device; no catalog line can collect it.

Contents of the state directory

From docs/openclaw-agent-runtime.md:49-60:

Outside the state directory

Logbook

Logbook, a plugin off by default that takes periodic screen snapshots from a paired device and summarises them into a timeline, keeps logbook/logbook.sqlite and the image files logbook/frames/ (extensions/logbook/openclaw.plugin.json:2-10); present at both 3b16db7 and 146284a, collected under .openclaw. The frames are screenshots of the user's screen and can be large; they are not excluded.

Docker

At 146284a the compose file bind-mounts ~/.openclaw and a second host directory, ~/.openclaw-auth-profile-secrets, at /home/node/.config/openclaw (docker-compose.yml:46-49), which is where gateway.env is read (infra/dotenv-global-core.ts:206). The catalog line openclaw|.openclaw-* collects it as if it were a profile directory; its contents are credentials (Credentials).

Legacy names

~/.clawdbot is still read and doctor --fix renames it to ~/.openclaw, leaving ~/.clawdbot as a symlink (Windows junction) to the new directory (infra/state-migrations.state-dir.ts:186,206,447-459). ~/.moltbot and moltbot.json detection was removed (CHANGELOG/2026.2.13.md:119, CHANGELOG/2026.3.22.md:13), so a ~/.moltbot on an old host is orphaned state that the current tool ignores. CLAWDBOT_* and MOLTBOT_* environment variables are ignored (gateway/env-deprecation.ts:7).

Credentials

Exclusions

The backup command's own skip list is a good guide (docs/cli/backup.md:649-653, 659-663): dev/, git/, npm/, npm-runtime/, tmp/ and tools/ (managed checkouts, packages, downloaded runtimes such as signal-cli and sherpa-onnx TTS models), extensions/*/node_modules, plugin-skills/ (a symlink index), sandbox/skills-workspaces/, and agents/*/agent/**/tmp. Add worktrees/ (git worktrees of user repositories, the equivalent of .claude/worktrees) and the Chromium cache subtrees under browser/*/user-data/. Do not exclude browser/*/user-data as a whole: its History and Cookies show what the agent browsed.

At 146284a the full git clones the Gateway makes when a project is added from a remote URL, projects/<16 hex>/<name>, are excluded too; the clone directory's name must be 16 lower-case hex characters (src/projects/project-clone.ts:276-295), so the pattern names it, .openclaw*/projects/ followed by sixteen [0-9a-f] classes, and nothing else under projects/ is pruned. They are the same kind of tree as worktrees/; which repository each one is stays in state/openclaw.sqlite projects (origin_url, repo_root). The volume-relative forms are projects/ with the same sixteen classes, plugin-skills, agents/*/agent/tmp and agents/*/agent/.tmp; dev, git and tmp cannot be added bare without matching other tools' volume layouts.

Project-local files

The agent workspace is the main "project". Inside user repositories OpenClaw reads .openclaw/worktree-setup.sh when it creates a managed worktree (agents/worktrees/service-preparation.ts:329) and the workspace bootstrap files listed in Per-user storage when a repository is configured as a workspace. A crashed memory migration leaves .openclaw-repair/root-memory/ (root-memory-files.ts:9). No per-project database.

Where the project path is recorded

Parsing

Transcript stores

Home-relative, with .openclaw replaceable by .openclaw-<profile> or the legacy .clawdbot:

The agent database uses a WAL journal (infra/sqlite-wal.ts:169), read through sqlite_util.py with sidecars. Payloads of 1 KiB to 4 MiB that shrink by at least 10% are stored as event_zstd (one zstd frame, level 1, checksum, no dictionary) with event_json NULL and the original length in event_utf8_bytes; others stay in event_json (config/sessions/transcript-payload.ts:22-24,215-272; infra/zstd-codec.ts:22-37). The parser needs zstandard. Cold storage moves old generations into session_transcript_cold_archives (blob, or a file in the sessions directory when storage='file', :633-648); archive_blob in both archive tables is JSONL, zstd when encoding='zstd'.

What survives a delete

openclaw sessions archive keeps the transcript and only sets archivedAt, archivedBy and archiveReason (manual|active-session-cap|age-retention|stale-dashboard|restart-recovery) in entry_json, mirrored in session_nodes.archived_at (docs/cli/sessions.md:181-196; src/config/sessions/types.ts:300-305; gateway-protocol/src/schema/sessions-row.ts:48-54). openclaw sessions delete removes the session row, its generations and bindings and keeps a .jsonl.deleted.<ts> archive with its session_transcript_archives blob (reason deleted) unless the session was incognito, as a reset keeps a .jsonl.reset.<ts> one (docs/cli/sessions.md:222-233; at 3b16db7, docs/cli/sessions.md:212-217).

The memory index keeps the session's memory_index_chunks until openclaw memory forget, which writes one memory_session_tombstones row (session_id, agent_id, reason, default forgotten, created_at) per forgotten session (openclaw-agent-schema.sql:699-704; extensions/memory-core/src/memory-session-tombstones.ts:89-106); inbound channel messages in channel_ingress_events are pruned by age and count, not by deletion, conversation_deliveries rows go only with their conversation, and an uploaded backup keeps the session. Pending inputs cascade away with the session, whether the full-text index rows go is not determined, and a public share grant is lost with the session row (Other records of past use, Sync, relay and origin). There is no prompt-history file.

The analyzer reads the archive blobs as primary and the published .jsonl.deleted.* and .jsonl.reset.* files as secondary. A deleted archive gives an archive=deleted.<ts> session-start row and a session deleted: row, a tombstone a session forgotten from memory: row, an archived session a session archived: row; the full-text index gives the entries the transcript no longer holds, the pending inputs the prompts that never reached it, the channel tables the inbound messages and deliveries, and the backups backup uploaded: rows. The memory index chunks are not read (see Secondary sources and Deletion).

Record schema

Transcript entries

Each transcript_events.event_json (or decompressed event_zstd) and each legacy JSONL line is one entry (agents/sessions/session-manager-types.ts:9-119):

  • Header: {type:"session", version, id, timestamp, cwd, parentSession?}, always the first record (config/sessions/transcript-header.ts:20-29; session-accessor.sqlite-transcript-header.ts:7-35). cwd defaults to the process cwd when the caller gives none.
  • Every other entry: type, id, parentId (null at the root), timestamp (ISO 8601 string, UTC from toISOString()), optional appendMode:"side". Types: message, thinking_level_change {thinkingLevel}, model_change {provider, modelId}, compaction {summary, firstKeptEntryId, tokensBefore, tokensAfter?, details?, fromHook?}, reset {reason: new|reset|idle|daily|cron-stale}, branch_summary {fromId, summary}, custom {customType, data}, custom_message {customType, content, display}, label {targetId, label}, session_info {name}. Unknown records with id/parentId (for example leaf) are preserved opaquely (config/sessions/session-entry-codec.ts:181-221).
  • message.message is an AgentMessage (packages/agent-core/src/types.ts:467-479), discriminated by role (packages/llm-core/src/types.ts:369-390,516-561):
  • user: content string or blocks, timestamp (epoch ms). runtimeContext marks injected runtime context, not human input.
  • assistant: content blocks, api, provider, model, responseModel?, responseId?, turnId?, usage {input, output, cacheRead, cacheWrite, totalTokens, cost{...}}, stopReason (stop|length|toolUse|error|aborted, :357), errorMessage?, errorBody?, providerReplay?, timestamp ms.
  • toolResult: toolCallId, toolName, content (text/image blocks), details?, isError, timestamp ms.
  • bashExecution: command, output, exitCode, cancelled, truncated, fullOutputPath?, timestamp ms (user-run ! commands, agent-core/src/types.ts:397-416).
  • custom {customType, content, display, details?}, branchSummary {summary, fromId}, compactionSummary {summary, tokensBefore} (its timestamp may be a string, :418-461).
  • Content blocks (llm-core/src/types.ts:264-313): text {text}, thinking {thinking, thinkingSignature?, redacted?}, image {data (base64), mimeType}, toolCall {id, name, arguments (object)}.

Row metadata

transcript_events.created_at is epoch ms, taken from the record's timestamp or Date.now() (session-accessor.sqlite-transcript-store.ts:173); seq orders a session from 0. session_windows: session_key, previous_session_id, reason (initial|reset|rollover|fork|rewind|switch|recovery|compaction), channel, account_id, chat_type, model_provider, model, started_at, ended_at, status, parent_session_key, spawned_by, display_name. session_nodes (:16-49): session_key, current_session_id, entry_json (session metadata), created_via (operator|spawn|channel|cron|...), created_actor_type/id, label.

Permission records

All in state/openclaw.sqlite; the tables are the same at 3b16db7 and 146284a, and the line numbers are those of the later commit:

  • operator_approvals, one row per approval request the Gateway raised for an exec (shell) call, a plugin action or a system agent: kind (exec|plugin|system-agent), status (pending|allowed|denied|expired|cancelled), decision (allow-once|allow-always|deny), terminal_reason (user|timeout|malformed-verdict|no-route|run-aborted|gateway-restart|storage-corrupt), resolver_kind (device|channel|runtime|system: the paired device, the chat channel, the runtime's automatic reviewer or the system), resolver_id, presentation_json (what the reviewer was shown), requested_by_device_id, source_agent_id, source_session_key, source_session_id, source_run_id, source_tool_call_id, source_tool_name, created_at_ms, resolved_at_ms, consumed_at_ms (openclaw-state-schema.sql:370-475). source_session_id and source_tool_call_id join it to the transcript's toolCall.id.
  • exec_approvals_config, the standing exec policy, migrated from the legacy ~/.openclaw/exec-approvals.json (infra/exec-approvals-config.ts:95-101): raw_json (the whole file: defaults and per-agent agents.<id> with security deny|allowlist|full, ask off|on-miss|always, askFallback, autoAllowSkills, an allowlist and mcpTools grants, and socket.token, a credential), plus default_security, default_ask, default_ask_fallback, auto_allow_skills, allowlist_count, updated_at_ms (openclaw-state-schema.sql:356-368; infra/exec-approvals-core.ts:12-15, :226-234). The exec mode auto is allowlist with on-miss and the automatic reviewer on (:96-115).
  • operator_approval_standing_grants: an allow-always decision on a scheduled (cron) job mints a grant, grant_id, minted_by_approval_id, agent_id, cron_job_id, operation_binding, created_at_ms, expires_at_ms, revoked_at_ms, last_used_at_ms, use_count (:512-529).
  • channel_pairing_allow_entries (channel_key, account_id, entry, updated_at): the chat senders allowed to talk to the agent per channel account (:794-801).
  • audit_events carries tool_call_id, tool_name, action, status, reason_code and actor_type/actor_id per action (:100-132); its kind and action values were not enumerated.
  • Per session, in session_nodes.entry_json: elevatedLevel, execHost, execNode, execCwd, toolOverrides (mcpServers on or off, mcpToolsDeny) and sendPolicy (allow|deny) (src/config/sessions/types.ts:481-500, :532).

Model override

session_nodes.entry_json keeps the session's chosen model apart from the transcript, providerOverride, modelOverride, modelOverrideSource (auto for a temporary runtime fallback, user for /model or sessions.patch, default), and modelOverrideFallbackOriginProvider/Model (the model the user had picked when a fallback replaced it) (src/config/sessions/types.ts:502-516). The assistant message's responseModel beside model is the resolved id beside the request (packages/llm-core/src/types.ts:535).

Joins and timestamps

  • Session: transcript_events.session_id = session_windows.session_id; the header id equals it. Generations of one conversation share session_key and chain through previous_session_id.
  • Session key encodes the origin: agent:<agentId>:<channel>:<accountId>:direct:<peerId>, agent:<agentId>:<channel>:<peerKind>:<peerId>, or agent:<agentId>:main-style keys (routing/session-key.ts:206-261). This is how a responder ties a transcript to a Telegram chat or WhatsApp number.
  • Relay (the same at 3b16db7 and 146284a; line numbers of the later commit). A session carried through a messaging platform is recorded three ways besides its key, each naming the platform's copy of the conversation: session_windows channel, account_id, chat_type (direct|group|channel), primary_conversation_id and hook_external_content_source (gmail|webhook, a session started by a Gmail or webhook hook) (openclaw-agent-schema.sql:140-171); conversations (conversation_id, channel, account_id, kind, peer_id, delivery_target, thread_id, native_channel_id, native_direct_user_id, label, created_at; :192-207), joined to a generation by session_conversations (session_id, conversation_id, role primary|participant|related, first_seen_at, last_seen_at; :257-267); and session_nodes.entry_json delivery, {kind:"none"}, {kind:"internal"} or {kind:"external", route, context, origin}, whose origin holds provider, surface, chatType, from, to, accountId, threadId, nativeChannelId and nativeDirectUserId (src/config/sessions/types.ts:60-83, :606), with session_nodes.created_via channel for a session a channel message created (openclaw-agent-schema.sql:28). The channel webchat is the Control UI itself, not a platform (utils/message-channel-constants.ts:3).
  • Tool call to result: toolCall.id in an assistant message equals toolResult.toolCallId in a later message entry.
  • Tree: parentId → id; transcript_event_identities indexes it (openclaw-agent-schema.sql:684-694).
  • Subagents: session_nodes.parent_session_key/spawned_by, and fork_source_session_id/fork_source_entry_id for forks; header parentSession.
  • Project: header cwd. No git branch is recorded.
  • Times: entry timestamp is an ISO 8601 UTC string, message timestamp and transcript_events.created_at are epoch ms (Record schema); how copies join the database rows is under Past use and secondary sources.

Other stores

Audit, ingress and debug logs

Not transcripts, but they tie actions to sessions: state/openclaw.sqlite audit_events, one row per action (columns in Permission records), the raw inbound chat messages of channel_ingress_events.payload_json (Other records of past use) and the debug traces under logs/ that hold full prompts. The files and tables are named in Contents of the state directory.

Other records of past use

Line numbers at 146284a; every source is present at 3b16db7 too, and the parser reads all but the memory index (Past use and secondary sources):

Source Holds Survives Joins on Plan
openclaw-agent.sqlite session_transcript_fts (FTS5, the SQLite full-text index; content in its _content shadow table) and session_transcript_fts_rows (openclaw-agent-schema.sql:809-823) user and assistant text with session_id, message_id, role, timestamp, the index behind the sessions_search tool (docs/concepts/session-search.md:10-13) rows are deleted explicitly when a generation moves to cold storage (session-cold-storage-worker.ts:519-521); session_transcript_fts_rows has no foreign key to session_windows, so whether a session delete removes them was not determined session_id, entry id (message_id), text, time secondary
openclaw-agent.sqlite session_pending_inputs (:848-864) and session_input_completions each accepted prompt as message_json with state queued, interrupted or cancelled and accepted_at, kept "outside the active transcript until its exact turn owns execution" a cancelled or interrupted prompt never reaches the transcript; the rows cascade away with the session session_id, session_key, consumed_event_id (the transcript entry once consumed) primary for rows never consumed, secondary otherwise
openclaw-agent.sqlite memory_index_chunks with source sessions (:658-670; the two sources are memory and sessions, memory-host-sdk/src/host/types.ts:5) chunks of transcript text with path, line range and updated_at a session delete: "retained deleted-session archives can remain eligible for memory search" until openclaw memory forget (docs/cli/sessions.md:222-233) path and text; no session id column secondary; the chunk path form was not determined
state/openclaw.sqlite channel_ingress_events (openclaw-state-schema.sql:1273-1294) each inbound channel message as payload_json, with channel_id, account_id, status, received_at pruned by age and count per status (pending, completed, failed), not by session deletion (channels/message/ingress-queue.kernel.ts:494-512) text and time; the payload shape per channel was not determined secondary
openclaw-agent.sqlite conversation_deliveries (:225-248) each outbound send or turn to a platform conversation: status, platform_message_id, reply_text, reply_timestamp, source_session_key cascades with the conversations row, not with the session source_session_key, time secondary

No prompt-history file exists: not found in openclaw/openclaw at 146284a (grepped history.jsonl, input-history, prompt-history and historyFile in src/).

Sharing and upload state

All in SQLite:

PostgreSQL engine

Experimental at 146284a and absent at 3b16db7, which has no PostgreSQL source file. With database.engine: "postgres" in openclaw.json the shared state, the agent databases (transcripts included), and the Workboard, Logbook and Team Reports stores live on a PostgreSQL server named by database.postgres.connection (a DSN string or a SecretRef); database.families.agent.engine moves the agent databases, transcripts included (docs/reference/database-engines.md:68-73, :96-116). state/openclaw.sqlite and each local openclaw-agent.sqlite then are "a small SQLite anchor" holding one table, openclaw_engine_anchor (store_id, engine, schema) with engine postgres and schema the server namespace <schemaPrefix>_<store>_<uuid-prefix> (:591-595; src/infra/database-engine/open.ts:149). The transcript is then on the database server, wherever the operator runs it, and no local file holds it; transcript payloads are plain JSON there, never zstd. A responder who finds an anchor needs the DSN (in openclaw.json or the environment, Credentials) and the schema value to request the server's copy. Daemon runtime pins stay local in control/services.sqlite on either engine (Contents of the state directory). The parser yields one whole-file row for an anchor, naming the store and the schema (Sync, relay and origin).

Format versions

Header version: current 4, minimum readable 3; versions 1-2 need doctor import and may lack id/parentId; the old hookMessage role becomes custom with customType:"hook" (config/sessions/version.ts:1-4; session-entry-codec.ts:154-179). Database schema version is in schema_meta.schema_version (openclaw-agent-schema.sql:6-14). Test fixtures for schema v1, v14, v15, v19, v21 and v22 are in test/fixtures/sqlite/.

At 146284a (2026-10-19) the agent schema is 31; the history lists 25 to 31 as unreleased changes to session-writer validation, transcript navigation columns, the context-engine outbox, index sizes, Brotli-compressed trajectory payloads and memory-index revisions (docs/reference/database-schemas/agent-schema-history.md:43-50). transcript_events gained navigation_* and message_role columns but keeps session_id, seq, event_json, event_zstd, event_utf8_bytes and created_at with the same zstd rule (openclaw-agent-schema.sql:437-482); the archive tables and session_windows keep every column the parser reads; the header version is still 4 (config/sessions/version.ts:2). The archive and checkpoint file-name patterns moved, unchanged, to config/sessions/transcript-file-names.ts:12-16; the exec tool name cited in Parser plan now comes from agents/lazy-exec-tool.ts:63, core-tool-factory-descriptors.ts being gone, and skills/runtime/refresh-source-roots.ts, cited in Contents of the state directory, has moved. No other drift changes what the parser reads; the one change that defeats the parser is the PostgreSQL engine (PostgreSQL engine).

Secrets

In the same database as the transcripts, openclaw-agent.sqlite: auth_profile_store.store_json and auth_profile_state.state_json (provider API keys and OAuth tokens), and session_suggestions (state/secret-state-tables.ts:31-35; openclaw-agent-schema.sql:742-752); do not emit them. In state/openclaw.sqlite, exec_approvals_config.raw_json keeps the approvals socket's socket.token after the legacy exec-approvals.json is migrated (Permission records). In records: thinkingSignature and providerReplay.data are opaque provider state; image.data is base64 media; toolResult.content and bashExecution.output are raw tool output and may contain anything the agent read, including .env files.

The key that encrypts public share tokens is derived from the Gateway's device identity, the device_identities row of state/openclaw.sqlite (src/gateway/control-ui-public-session-token.ts:196-217); whoever holds that database can mint working share URLs for every publicShare grant. Offsite backup credentials (accessKeyId, secretAccessKey) and the encryption passphrase are configured under storage.locations in the config file as SecretRefs (references to a value held in the environment or a secret store, not the value itself) (docs/concepts/storage-locations.md:79-91).

Parser plan

Selection: */agent/openclaw-agent.sqlite under an openclaw artifact (plus -wal); */sessions/*.jsonl, *.jsonl.reset.*, *.jsonl.deleted.* (decompress .zst), *.checkpoint.<uuid>.jsonl and *.jsonl.pre-doctor-*-repair-*.bak. Skip *.trajectory.jsonl and *.migrated* (see Past use and secondary sources). SQL: SELECT e.rowid, e.session_id, e.seq, e.event_json, e.event_zstd, e.created_at, w.model, w.channel, w.session_key FROM transcript_events e LEFT JOIN session_windows w USING (session_id) ORDER BY e.session_id, e.seq. Then archive blobs.

Column Source
timestamp_utc entry timestamp; else message.timestamp (ms); else created_at (ms)
session_id session_id / header id
project_path header cwd of the session
git_branch empty
turn_type role user → user; assistant text → assistant, toolCall block → tool_use, thinking block → thinking; toolResult → tool_result; bashExecution → tool_use (tool bash); other entry types and roles → system
model assistant responseModel or model; model_change.modelId
tool_name toolCall.name / toolResult.toolName
tool_use_id toolCall.id / toolResult.toolCallId
text text blocks; toolCall.arguments.command/path/url else JSON; tool result text; summary for compaction; reason for reset
source_line transcript_events.rowid, or the JSONL line number

Put session_key (channel and peer) into the system row for each session. The shell tool is named exec (agents/core-tool-factory-descriptors.ts:20); api values include anthropic-messages (llm-core/src/types.ts:23).

The rows added at 146284a rest on source alone and each subsection below marks them; the per-step real-install round AGENTS.md requires is still owed.

Permission rows

No transcript entry records a per-call permission decision; bashExecution.cancelled stays on the bash tool_result row. The approvals and the standing policy are rows of state/openclaw.sqlite and of each session's entry_json, whose columns are in Permission records. The parser yields, all source-derived; not observed on any real install:

  • One row per operator_approvals row, at resolved_at_ms (created_at_ms while pending), with session_id source_session_id, tool_name source_tool_name and tool_use_id source_tool_call_id, so it sits beside the call it governed: permission decision: <decision, else status> status=<status> kind=<kind> resolver=<resolver_kind> reason=<terminal_reason> approval <approval_id> (from state/openclaw.sqlite operator_approvals, <resolved_at_ms or created_at_ms>).
  • One permission setting: <scope>.<setting> (from state/openclaw.sqlite exec_approvals_config, updated_at_ms) row per security, ask, askFallback and autoAllowSkills value (<key>=<value>), per allowlist entry (allowlist=<pattern>[ argPattern=<argPattern>] source=<source>) and per MCP tool grant (mcpTools=<server>/<tool> source=<source>) of raw_json's defaults and of each agents.<id>, <scope> being defaults or agents.<id> (packages/gateway-protocol/src/schema/exec-approvals.ts:15-75). socket is never read (Secrets); a raw_json that does not decode is one parser: row that withholds the text, since the text holds the token.
  • permission setting: standing grant <grant_id> allow-always cron job <cron_job_id> agent <agent_id> binding <operation_binding> approval <minted_by_approval_id> expires=<expires_at_ms> revoked=<revoked_at_ms> uses=<use_count> (from state/openclaw.sqlite operator_approval_standing_grants, created_at_ms) per standing grant.
  • permission setting: channel <channel_key> account <account_id> allowFrom=<entry> (from state/openclaw.sqlite channel_pairing_allow_entries, updated_at) per allowed sender.
  • One permission setting: <key>=<value> (from openclaw-agent.sqlite session_nodes.entry_json, updated_at) row per elevatedLevel, execHost, execNode, execCwd, toolOverrides (as JSON) and sendPolicy of a session's entry_json, after the start row of its current generation, at session_nodes.updated_at.

audit_events is not read: its kind and action values were not enumerated.

Model rows

system row model: <id> provider=<provider> at the first model a session names and model: <old> -> <new> provider=<provider> on every later entry whose model differs, with the model column set to the new id. Sources, in order: the session_windows model and model_provider (the row follows the session-start row, at its time), model_change modelId and provider, and the assistant responseModel, else model, with provider; when responseModel differs from model the row ends  requested=<model>. responseModel is the concrete model the stream reported "when different from the requested model" (llm-core/src/types.ts:533). A model_change entry naming the model already in force still yields the plain model: <id> provider=<provider> row, since it records a switch; an assistant message that repeats the current model yields none.

The session's model override (Model override) gives one model: <modelOverride> provider=<providerOverride> source=<modelOverrideSource> (from openclaw-agent.sqlite session_nodes.entry_json modelOverride, updated_at) row, with  fallback_from=<provider>/<model> before the parenthesis when modelOverrideFallbackOriginProvider or Model is set, after the start row of the session's current generation, at session_nodes.updated_at, the model column the override. It records a /model the user typed even when no turn followed. The entry keeps only the current override, so the row names no previous model. Source-derived; not observed on any real install.

Past-use rows and secondary sources

Every source is parsed on every run, and a row of a secondary source is dropped when a primary store's row records the same event (issue 122; see Secondary sources in analyzer/docs/output.md).

  • Primary: openclaw-agent.sqlite, all of it: transcript_events, the cold archives and every session_transcript_archives blob, whether or not its published_at is set (the time the blob was also written to the sessions directory as a file). The schema calls that table the "canonical cold-tier owner for reclaimed transcript generations", from which "the derived .deleted/.reset file may be recreated" (openclaw-agent-schema.sql:606-607); the file is published from the blob's exact bytes, hash-checked (session-accessor.sqlite-archive-artifact.ts:177-216). openclaw doctor (the CLI's repair and migration command) "repairs canonical blobs before their reconstructible files", rewriting a published file from its row (state-migrations.transcript-directives-archives.ts:327-379), so the table, not the file, is the store of record. The legacy plain <sessionId>.jsonl is primary too: before doctor imports it, it is the only copy.
  • Secondary, the published .jsonl.reset.* / .jsonl.deleted.* file: the same entries as the blob. It survives the database being lost or excluded; otherwise the blob outlives it, since retention deletes a published row only once its file is gone (session-history-archive-pruning.worker.ts:105-127).
  • Secondary, <name>.checkpoint.<uuid>.jsonl: a pre-compaction snapshot, a byte copy of the session file taken before compaction (src/gateway/session-compaction-checkpoints.ts:383-389 at 6814525, the parent of the commit that stopped copying, c59635a). At 3b16db7 no runtime writes new ones; the session store keeps only the references (legacy-compaction-history.ts:3-7), so the files exist on hosts that ran an older release. A checkpoint holds the entries a compaction later summarised, which survive in the legacy file or, after import, in transcript_events; it survives a compaction of the live transcript but is a copy of nothing the primary store lost unless that store is gone. <name> is the session file's name, <sessionId> or, for a session forked from a checkpoint, <ts>_<sessionId> (session-compaction-checkpoints.ts:320-332 at 6814525).
  • Secondary, <sessionId>.jsonl.pre-doctor-(branch|openai-codex)-repair-<ts>.bak: a byte copy of a legacy transcript taken before openclaw doctor rewrote it (src/commands/doctor-session-transcripts.ts:249-292 at 6559e3f, the parent of 4273ca9, which removed the file-era runtime; the name pattern is still classified at artifacts.ts:13-14). A branch repair keeps only the active branch, so the backup also holds the abandoned branches, which no other store has, and its rows for them are kept.
  • Not read: <id>.trajectory.jsonl, the trajectory flight recorder (a per-run log of runtime events: prompt submitted, tool call, model completed), a different schema marked openclaw-trajectory (docs/tools/trajectory.md:76-102), not a transcript copy; *.migrated[.N] (artifacts.ts:15), for which no writer of a session transcript was found at 3b16db7 or 6559e3f (the suffix is used there for migrated cron logs, pairing stores and audit logs); and <id>.jsonl.bak.<ts>, the legacy compact backups (artifacts.ts:43-50), whose content is not recorded.

Joins: the copies are byte copies, so their entries carry the same id, timestamp and header cwd as the primary store's rows, and the parser takes the session id of every JSONL file from its header id (the name before the suffix only when there is no header), the id transcript_events.session_id and the archive table carry. An archive whose session id is over 96 bytes is named session-<sha256> (session-accessor.sqlite-archive-artifact.ts:24-29), which the header id overrides. Every entry row of a copy therefore pairs with its primary twin at the same time (a 2-second tolerance is not needed but is the default). The session-start row does not: a copy's names the file (archive=<reason>.<ts> or copy=<suffix>) where the database's carries the session_windows and archive columns, so each copy keeps one row showing it exists.

Covered: the archive blobs and their files, checkpoints and pre-doctor copies, and, source-derived; not observed on any real install, these tables of Other records of past use. The full-text index and the pending inputs repeat transcript entries inside the primary database, where the file-level secondary flag cannot mark them, so the parser joins them on the entry id itself and marks what it keeps recovered:

  • session_pending_inputs, each accepted prompt, message_json a user message (role user, content; src/config/sessions/session-pending-input-value.ts:8-15): a row with no consumed_event_id is primary and yields a user row and pending input: <state>, not consumed by the transcript (input <input_id> run <run_id>; from openclaw-agent.sqlite session_pending_inputs, accepted_at), at accepted_at; a consumed row yields its user row, recovered, only when no transcript entry of the database has the consumed_event_id. A message_json that does not decode is its own parser: row.
  • session_transcript_fts, read from its FTS5 content table session_transcript_fts_content as a plain table, its columns c0 to c4 being text, session_id, message_id, role and timestamp in the declared order (openclaw-agent-schema.sql:809-816): a user or assistant row (else system, <role>: <text>) per indexed entry whose (session_id, message_id) no transcript row carries, recovered.
  • conversation_deliveries joined to conversations, with session_id the current generation of source_session_key: channel delivery: <operation_kind> <status> channel <channel> conversation <conversation_id> platform message <platform_message_id> (from openclaw-agent.sqlite conversation_deliveries, created_at), with  error=<rejection_error> before the parenthesis for a rejected send, and, when a platform message replied to the delivery (reply_message_id, reply_text, reply_timestamp; src/config/sessions/conversation-delivery-store.types.ts:24-30), channel delivery reply: <reply_text> (reply message <reply_message_id>; from openclaw-agent.sqlite conversation_deliveries, reply_timestamp). The outbound text itself is stored only as message_hash, so the rows repeat no transcript text.
  • channel_ingress_events of state/openclaw.sqlite: channel ingress: <channel_id> account <account_id> status <status>: <payload_json> (from state/openclaw.sqlite channel_ingress_events, received_at), the payload whole since its shape per channel was not determined, with no session id; it cannot be matched to the transcript.

memory_index_chunks waits until the form of its path for a session is known.

Sync, relay and origin rows

Sync: OpenClaw keeps no copy of a session on a vendor service. Session content leaves the Gateway host in three ways, all operator actions; the first two leave a local record (Sharing and upload state). Both are covered (session synced: public share, backup uploaded:).

  • Public link: session_nodes.entry_json publicShare marks a session whose user messages and final answers anyone with the URL can read, served by the Gateway (opt-in per session, off by default, docs/concepts/multi-user.md:21-36). The parser yields one system row after the generation's session-start row: session synced: public share <publicShare.id> generation <publicShare.sessionId> (from openclaw-agent.sqlite session_nodes.entry_json, publicShare.createdAt), timestamped by createdAt, with session_id the generation, and sets records_sync; a grant whose generation has no transcript rows in the database still yields its row. Fixture only: no real install has shown the key. Turning access off removes the key, and deleting the session deletes its session_nodes row (src/config/sessions/session-accessor.sqlite-entry-store.ts:271-274); the .jsonl.deleted.* archive and its session_transcript_archives blob keep the transcript but have no column for the grant (src/state/openclaw-agent-schema.sql:608-624), so no local file says a deleted or unpublished session was ever public.
  • Backup upload: openclaw backup create --to <location> uploads the whole state directory, every agent database included, to a configured storage location (a mounted disk, or Cloudflare R2, the Cloudflare object store, through a plugin) under backups/<namespace>/<UTC ts>-<8 hex>.tar.gz, and openclaw backup git create --push pushes per-table JSONL dumps to a Git remote; both are opt-in and can be scheduled with openclaw backup enable (docs/cli/backup.md:86-96, docs/cli/backup.md:318-321, docs/cli/backup.md:364). The marker is host-wide, not per session: each backup_runs row says every session in the uploaded databases at created_at left the host. The parser yields one system row per backup_runs row with status ok and either a location or a Git target without pushFailed, with an empty session_id, in the agent's own term rather than session synced: because it marks no one session: backup uploaded: <kind> to <location.name or archive_path> key <location.key or target> namespace <location.namespace or namespace or -> (from state/openclaw.sqlite backup_runs, created_at), timestamped by created_at. A Git backup run without --push records the same manifest as a pushed one, target the commit and no pushFailed (src/commands/backup-git.ts:140-151), so a Git row says a commit was made and may have been pushed. Fixture only. Deleting a session does not touch backup_runs, and the uploaded archives keep the session; retention prunes the table (docs/cli/backup.md:486-499).
  • Remote execution: a session placed on a paired device or a Crabbox cloud worker (a machine OpenClaw rents from AWS, Hetzner or another backend for the session) sends its workspace and turn context to that machine while the Gateway keeps the canonical transcript (docs/gateway/cloud-sessions.md:10-20). This is execution, not a copy kept elsewhere, and no row is proposed; worker_session_placements names the environment while the placement lives.

Not sync: the daily update check and the opt-in anonymous statistics carry no prompt or reply text, only a session-creation count (docs/gateway/telemetry.md:11-24); OpenTelemetry export sends message text only when diagnostics.otel.captureContent is true, to the operator's own collector (docs/gateway/opentelemetry/privacy-and-trace-context.md:47-56). Searched share, publicShare, sync, upload, cloud, remote, backup, telemetry, export and publish in src/ and docs/.

Relay (records_relay; source-derived; not observed on any real install): the records are under Joins and timestamps. After a generation's start row the parser yields one session relayed: <channel> account <account_id> <kind> peer <peer_id> thread <thread_id> role <role> (from openclaw-agent.sqlite session_conversations and conversations, first_seen_at) row per session_conversations link, at first_seen_at; for a generation with no such link, session relayed: <channel> account <account_id> <chat_type> (from openclaw-agent.sqlite session_windows, started_at) from its window; and for a session with neither, from an entry_json.delivery of kind external, session relayed: <route.channel> account <account> <origin.chatType> from <origin.from> to <origin.to> (from openclaw-agent.sqlite session_nodes.entry_json delivery, updated_at) (route is a channel route whose channel is the lower-case channel id, src/plugin-sdk/channel-route.ts:18-25). A window whose hook_external_content_source is gmail, a session a Gmail hook started, is an email relay: session relayed: gmail hook (from openclaw-agent.sqlite session_windows hook_external_content_source, started_at). The Control UI channel webchat and the internal wake sources heartbeat, cron, webhook, voice and sessions_send are not platforms and give no row (src/utils/message-channel-constants.ts:3, :14-20). The start row still carries channel, account_id and chat_type.

Origin: the agent records none (no session is created on a vendor server; grepped origin and remote in src/config/sessions/). A PostgreSQL anchor (PostgreSQL engine) yields one whole-file row, source-derived; not observed on any real install: parser: agent database is a PostgreSQL anchor (store <store_id>, schema <schema>): transcripts are on the database server, or for state/openclaw.sqlite parser: state database is a PostgreSQL anchor (store <store_id>, schema <schema>): the Gateway state is on the database server, so the gap is visible.

Scratch directory rows

The scratch directories are under the state directory, which the analyzer's catalog rule classes as scratch; proposed, a scratch directory: row per state/openclaw.sqlite projects row whose source is cloned and per worktrees row, so a state directory moved by OPENCLAW_STATE_DIR is classed too.

Deletion rows

A deleted archive shows as an archive=deleted.<ts> session-start row (What survives a delete) and, source-derived; not observed on any real install, three rows mark a delete, an archive and a forget:

  • session deleted: <session_id> archive <archive_name> (from openclaw-agent.sqlite session_transcript_archives reason deleted, created_at) after the rows of each session_transcript_archives blob whose reason is deleted, at created_at, even when the blob does not decompress.
  • session archived: <archiveReason> by <archivedBy.type>:<archivedBy.id> (from openclaw-agent.sqlite session_nodes.entry_json, archivedAt) after the start row of the session's current generation, at archivedAt; archivedBy is a {type, id, label} actor (src/config/sessions/session-entry-provenance.ts:16-18).
  • session forgotten from memory: <session_id> agent <agent_id> reason=<reason> (from openclaw-agent.sqlite memory_session_tombstones, created_at) per tombstone, with the cwd of the session's transcript when the database still holds one.

Bad-line rows

Issue 177: a legacy sessions.json that cannot be read, does not decode or is not a JSON object yields one whole-file system row in each transcript beside it, after that transcript's line rows, with source_line 0 and the transcript's session_id and cwd: parser: sessions.json unreadable (<reason>), parser: sessions.json unparseable (<reason>) or parser: sessions.json is not a JSON object; the transcript's own rows are still yielded, without the session key. A line that is valid JSON but not an object (an array, a string, a number, null), in a JSONL file or an archive blob, is its own row like any other bad line, parser: unparseable line (not a JSON object): <line> (prefixed archive <name> line <n>:  inside a blob), and the lines around it are still yielded.

Fixtures

JSONL form; the SQLite form stores the same strings in event_json:

{"type":"session","version":4,"id":"7d0c2a8e-1111-4000-8000-000000000001","timestamp":"2026-10-01T09:00:00.000Z","cwd":"/home/alice/.openclaw/workspace"}
{"type":"message","id":"a1b2c3d4","parentId":null,"timestamp":"2026-10-01T09:00:01.000Z","message":{"role":"user","content":[{"type":"text","text":"check disk space on the server"}],"timestamp":1759309201000}}
{"type":"message","id":"b2c3d4e5","parentId":"a1b2c3d4","timestamp":"2026-10-01T09:00:04.000Z","message":{"role":"assistant","content":[{"type":"thinking","thinking":"Use exec with df."},{"type":"text","text":"Checking."},{"type":"toolCall","id":"call_01","name":"exec","arguments":{"command":"df -h"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-5","usage":{"input":900,"output":40,"cacheRead":0,"cacheWrite":0,"totalTokens":940,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"toolUse","timestamp":1759309204000}}
{"type":"message","id":"c3d4e5f6","parentId":"b2c3d4e5","timestamp":"2026-10-01T09:00:05.000Z","message":{"role":"toolResult","toolCallId":"call_01","toolName":"exec","content":[{"type":"text","text":"/dev/sda1  50G  20G  30G  40% /"}],"isError":false,"timestamp":1759309205000}}
{"type":"model_change","id":"d4e5f6a7","parentId":"c3d4e5f6","timestamp":"2026-10-01T09:01:00.000Z","provider":"openai","modelId":"gpt-5"}

A session_nodes.entry_json with the delivery route, model override and permission keys the parser reads (src/config/sessions/types.ts:60-83, :481-516, :532), and the raw_json of an exec_approvals_config row, whose socket.token the parser never reads:

{"sessionId":"7d0c2a8e-1111-4000-8000-000000000001","delivery":{"kind":"external","route":{"channel":"telegram","accountId":"default"},"context":{},"origin":{"provider":"telegram","chatType":"direct","from":"123456789"}},"providerOverride":"openai","modelOverride":"gpt-5","modelOverrideSource":"user","elevatedLevel":"on","execHost":"gateway","toolOverrides":{"mcpServers":"off"},"sendPolicy":"allow"}
{"version":1,"socket":{"path":"/home/alice/.openclaw/exec.sock","token":"<redacted>"},"defaults":{"security":"allowlist","ask":"on-miss"},"agents":{"main":{"autoAllowSkills":true,"allowlist":[{"pattern":"df","source":"allow-always"}],"mcpTools":[{"server":"github","tool":"search","source":"allow-always","addedAt":1}]}}}

Source host rows

OpenClaw connects a GitHub account as a managed identity per Gateway or per agent, an OAuth sign-in or a GitHub App installation, which its bundled GitHub plugin (reader, github_read and github_search tools) and its publication flow use (extensions/github/README.md:57-59; src/config/zod-schema.github-identity.ts:6-43). The token is local: the OAuth record is a secret_store_entries row and an App's private key is in openclaw.json (Credentials). A GitHub App installation lists the repositories it may reach as repositories {id, fullName} in that config entry (same lines). The repository-bound records are the GitHub publication requests of state/openclaw.sqlite, github_publication_requests, github_personal_publication_requests and github_repository_publication_requests, one row per push or pull request the Gateway made for a session, with identity_source (system-detected|system-configured|agent-override|personal), identity_account_id, identity_login, status, repository, branch, pull_request_url and created_at_ms (openclaw-state-schema.sql:2093-2140, :2410-2482, :2488-2531). Each yields, source-derived; not observed on any real install, source host: github account <identity_login> id <identity_account_id> identity <identity_source>; publication <status> repository <repository> branch <branch> pull request <pull_request_url> (from state/openclaw.sqlite <table>, created_at_ms) at created_at_ms, session_id the session. The App installation's repository list is not read, since the parser reads no openclaw.json (JSON5), so it gives no source host repository: rows.

Git remote rows

Each row of the state/openclaw.sqlite projects table (Where the project path is recorded) whose origin_url is set yields, source-derived; not observed on any real install, git remote: <origin_url> (system) at the row's created_at_ms, with project_path its repo_root and an empty session_id: the table names a repository the Gateway registered or cloned, not a session (issue 253). Its other columns are not read.

Confidence

  • High: state directory resolution, profiles, the legacy .clawdbot migration, database file names, table and column names and schemas, workspace and agentDir defaults, archive file naming, credential tables, service names; entry and message field names, the compression rule and the version constants.
  • Medium: the exclusion list comes from the backup documentation rather than each writer; models.json holding literal keys (inferred from the doctor migration); browser/*/user-data/*/Cache assumes Chromium's usual Default/ profile layout; the .moltbot layout is assumed identical to .clawdbot (Legacy names); that legacy .jsonl files are removed after import on every path; cwd meaning the workspace.
  • Not determined: the Windows log location beyond os.tmpdir(); what the macOS transcript cache stores; whether the iOS and Android apps keep transcripts on the phone; the shapes of trajectory_runtime_events, acp_parent_stream_events and custom.data per customType; whether cold-archived generations keep any transcript_events rows.