Session Management Deep Dive: Store, Transcripts, Lifecycle, and Compaction Internals
This page explains how the Gateway manages session state, including the two persistence layers for session metadata and transcript events. It is intended for developers and operators who need to understand session lifecycle and compaction internals.
Read this when
- You need to debug session ids, transcript events, or session row fields
- You are changing auto-compaction behavior or adding "pre-compaction" housekeeping
- You want to implement memory flushes or silent system turns
A single Gateway process owns all session state from start to finish. User interfaces such as the macOS app, web Control UI, and TUI ask the Gateway for session lists and token counts. In remote mode, session files reside on the remote host, so inspecting your local Mac's files won't show what the Gateway is actually using.
Start with the overview docs: Session management, Compaction, Memory overview, Memory search, Session pruning, Transcript hygiene, and the full config reference at Agent config.
Two persistence layers
- Session rows (per-agent SQLite) - a key/value map
sessionKey -> SessionEntry. This mutable runtime state belongs to the Gateway. It holds metadata: the current session id, last activity time, toggles, and token counters. - Transcript events (per-agent SQLite) - append-only and tree-structured, where entries include
idplusparentId. This stores the conversation, tool calls, and compaction summaries, and rebuilds model context for later turns. Compaction checkpoints are metadata on top of the compacted successor transcript; a new compaction does not produce a second.checkpoint.*.jsonlcopy.
Older installations might still contain sessions.json files under the agent sessions/ directory. Consider those files as legacy session-row migration inputs or explicit offline-maintenance targets. On Gateway startup and during openclaw doctor --fix import, legacy rows and transcript history are automatically hot-loaded into the per-agent SQLite store. Run openclaw doctor --session-sqlite inspect --session-sqlite-all-agents, then follow the Doctor migration sequence, when you need explicit inspection or validation evidence. If a migration fails after legacy transcript artifacts were archived, use the Doctor recovery mode from that sequence. Recovery uses migration manifests, restores only the affected archived support artifacts, prepares a sanitized GitHub issue report when requested, and does not make active runtime read JSONL files again.
Gateway history readers avoid loading the entire transcript unless the surface requires arbitrary historical access. First-page history, embedded chat history, restart recovery, and token/usage checks use bounded tail reads from SQLite. Full transcript scans go through the async transcript index and are shared across concurrent readers.
On-disk locations
Per agent, on the Gateway host (resolved via src/config/sessions.ts):
- Runtime session row store:
~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite - Runtime transcript rows:
~/.openclaw/agents/<agentId>/agent/openclaw-agent.sqlite - Legacy/archive transcript artifacts:
~/.openclaw/agents/<agentId>/sessions/ - Legacy row migration input:
~/.openclaw/agents/<agentId>/sessions/sessions.json
Store maintenance and disk controls
session.maintenance governs automatic maintenance for SQLite session rows, SQLite transcript rows, archive artifacts, and trajectory sidecars:
| Key | Default | Notes |
|---|---|---|
mode | "enforce" | or "warn" (report only, no mutation) |
pruneAfter | "30d" | stale-entry age cutoff |
maxEntries | 500 | cap on session entries |
resetArchiveRetention | keep (no age cutoff) | age cutoff for *.reset.*/*.deleted.* transcript archives; a duration opts into deletion |
maxDiskBytes | 10gb | per-agent sessions disk budget; false disables |
highWaterBytes | 80% of maxDiskBytes | target after budget cleanup |
A reset advances the live sessionKey -> sessionId mapping but keeps the previous SQLite session, transcript, trajectory, and search rows. That history stays searchable under the same session key; ordinary entry and session lists show only the new live mapping. Retained reset history is bounded by the disk budget, not by resetArchiveRetention, which only ages archive artifacts. Explicit deletion is different: it writes and verifies a compressed transcript archive (*.jsonl.deleted.<timestamp>.zst when zstd is available) before removing the deleted session's rows.
maxDiskBytes enforcement uses physical bytes: the per-agent SQLite main file, its -wal file, and counted files in the agent sessions directory. It never estimates row JSON sizes or subtracts logical row sizes from that total.
Gateway model-run probe sessions (keys matching agent:*:explicit:model-run-<uuid>) get a separate, fixed 24h retention. This pruning is pressure-gated: it only runs when session-entry maintenance/cap pressure is reached, and only before the global stale-entry cleanup/cap step. Other explicit sessions do not use this retention.
When combined physical usage exceeds maxDiskBytes, mode: "enforce" first reclaims checkpointable database space, then removes the oldest retained reset/delete archives. If usage is still above highWaterBytes, it walks historical SQLite sessions by sessions.updated_at, oldest first. Historical means the session id is not referenced by a live session entry, a route target, or an admitted/in-flight run. For each victim, cleanup writes, fsyncs, and reads back the compressed archive before a write transaction removes the session row and its transcript, trajectory, active, index, and FTS projections. This includes sessions that contain trajectory events but no transcript events. Cleanup rechecks route, entry, and admission references at deletion time, remeasures physical usage after each archive or session victim, and stops at highWaterBytes.
Committed writes and deletion first land in the WAL. Cleanup checkpoints it so the WAL can shrink immediately, then uses incremental vacuum to return eligible free tail pages from the main file; pages that are not yet reclaimable stay in the main file and therefore remain counted on the next physical measurement. mode: "warn" reports the current physical overage without checkpointing, writing an archive, or deleting rows.
Run maintenance on demand:
openclaw sessions cleanup --dry-run
openclaw sessions cleanup --enforce
Maintenance keeps durable external conversation pointers such as group sessions and thread-scoped chat sessions, but synthetic runtime entries (cron, hooks, heartbeat, ACP, sub-agents) can still be removed once they exceed the configured age, count, or disk budget. Isolated cron runs use a separate cron.sessionRetention control, independent of model-run probe retention.
Normal Gateway writes are routed through the session accessor, which serialises per-agent SQLite mutations along the runtime writer path. Runtime code ought to favour the accessor helpers in src/config/sessions/session-accessor.ts; the older sessions.json helpers serve as migration and offline maintenance utilities. When a Gateway is reachable, non dry run openclaw sessions cleanup and openclaw agents delete forward store mutations to the Gateway so that cleanup enters the same writer queue; --store <path> provides the explicit offline repair path for a designated legacy store and always remains local (the same applies to --dry-run). maxEntries cleanup is batched for production scale stores, so a store may temporarily go over the configured cap until the next high water cleanup rewrites it back down. Reads never prune or cap entries during Gateway startup, only writes or openclaw sessions cleanup --enforce do, and the latter also applies the cap immediately and prunes old unreferenced legacy transcript, checkpoint, and trajectory artifacts even when no disk budget is configured.
OpenClaw no longer creates automatic sessions.json.bak.* rotation backups during Gateway writes. The current schema rejects the legacy session.maintenance.rotateBytes key, and openclaw doctor --fix removes it from older configurations.
Transcript mutations use the session write queue for the SQLite transcript target:
Session write locks use fixed production defaults. The corresponding
OPENCLAW_SESSION_WRITE_LOCK_* environment variables remain available for
process level diagnostics and emergency overrides.
Downgrading After The SQLite Flip
Restore archived legacy transcript artifacts before running an older file backed OpenClaw version:
openclaw doctor --session-sqlite restore --session-sqlite-all-agents
The migration leaves legacy sessions.json files in place for support and
rollback, but hot transcript JSONL files that were imported into SQLite are
renamed into session-sqlite-import-archive/. Older file backed runtimes follow
the sessionFile paths in sessions.json, so they need those artifacts restored
before startup. Restore uses migration manifests, moves only recorded archived
artifacts whose original paths are missing, and leaves the SQLite database in
place for forward recovery.
Sessions created after the SQLite flip are SQLite only and will not appear to an older file backed runtime. If you re upgrade after a downgrade, run the Doctor inspection and validation sequence again so OpenClaw can verify restored legacy artifacts before importing.
Cron sessions and run logs
Isolated cron runs create their own session entries and transcripts with dedicated retention:
cron.sessionRetention(default"24h") prunes old isolated cron run sessions from the store;falsedisables.- Run history keeps the newest 2000 terminal rows per cron job. Lost rows retain their 24 hour cleanup window.
When cron force creates a new isolated run session, it sanitizes the previous cron:<jobId> session entry before writing the new row: it carries safe preferences (thinking, fast, verbose, reasoning settings, labels, display name) and explicit user selected model and auth overrides, but drops ambient conversation context (channel and group routing, send and queue policy, elevation, origin, ACP runtime binding) so a fresh isolated run cannot inherit stale delivery or runtime authority from an older run.
Session keys (sessionKey)
A sessionKey determines which conversation group you belong to (routing and isolation). Canonical rules: /concepts/session.
| Pattern | Example |
|---|---|
| Main/direct chat (per agent) | agent:<agentId>:<mainKey> (default main) |
| Group | agent:<agentId>:<channel>:group:<id> |
| Room/channel (Discord/Slack) | agent:<agentId>:<channel>:channel:<id> or ...:room:<id> |
| Cron | cron:<job.id> |
| Webhook | hook:<uuid> (unless overridden) |
Session ids (sessionId)
Each sessionKey references a current sessionId (the SQLite transcript identity that carries the conversation forward). Decision logic is implemented in initSessionState() inside src/auto-reply/reply/session.ts.
- Reset (
/new,/reset) generates a freshsessionIdfor thatsessionKey. - No automatic reset is the default behaviour. The existing
sessionIdcontinues, and compaction keeps the active model context within limits. - Daily reset (
session.reset.mode: "daily") produces a newsessionIdon the first message after the configured local-hour boundary (session.reset.atHour, default4). - Idle expiry (
session.reset.mode: "idle"withsession.reset.idleMinutes, or legacysession.idleMinutes) produces a newsessionIdwhen a message arrives after the idle period. If both daily and idle are set, the one that expires sooner takes precedence. - Control UI reconnect resume keeps the currently visible session for a single reconnect send when the Gateway receives the matching
sessionIdfrom an operator UI client. This fires only once; ordinary stale sends still trigger a newsessionId. - System events (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may modify the session row but never refresh daily or idle reset timing. Reset rollover discards queued system-event notices from the previous session before building the fresh prompt.
- Parent fork policy uses OpenClaw's active branch when creating a thread or subagent fork. If that branch exceeds a fixed internal limit (currently 100K tokens), OpenClaw starts the child with isolated context rather than failing or inheriting unusable history. Sizing is automatic and not configurable; legacy
session.parentForkMaxTokensconfig is removed byopenclaw doctor --fix. - Operator forks:
sessions.create { parentSessionKey, fork: true }creates a new session whose transcript branches from the parent's current state (same fork mechanism as subagent spawns, including the size cap above). The fork is refused while the parent has an active run, inherits the parent's model selection unless one is explicitly provided, and marks the childforkedFromParentwith fresh token counters.
Session store schema
The runtime store holds SessionEntry values inside per-agent SQLite databases. The value's data type is SessionEntry in src/config/sessions.ts. Important key fields include (not a complete list):
sessionId: identifies the current transcript, used to reference SQLite transcript rowssessionStartedAt: records when the currentsessionIdbegan; daily reset freshness relies on this. Older rows might obtain this value from the JSONL session header.lastInteractionAt: marks the most recent real user or channel interaction time; idle reset freshness depends on this, meaning heartbeat, cron, and exec events do not prevent session expiry. Legacy entries missing this field fall back to the recovered session start time.updatedAt: indicates the last time a store row was mutated, used for listing, pruning, and bookkeeping, not for daily or idle freshness decisions.archivedAt: optionally records when the session was archived. Archived sessions remain in the store with their transcript intact but do not appear in normal active listings.pinnedAt: optionally records when the session was pinned. Active pinned sessions appear before unpinned ones in listings; archiving a session removes its pin.- Codex thread interop: both fields follow the Codex thread management pattern. The
archived/pinnedboolean values sent over the wire are always computed from the timestamp and set on the server side, matching Codexthreads.archived_atconventions and camelCase serialization. OpenClaw timestamps are in epoch milliseconds, while Codex uses epoch seconds, so bridges perform conversion at thecodexplugin boundary. Codex does not yet offer a pin API (onlythread/archive/thread/unarchive), so pinned state remains on the OpenClaw side until one exists; at that point the matching structure allows bound sessions to mechanically round-trip pin state. - Codex supervision lists only non-archived native threads. A Gateway-local
idleornotLoadedthread whose activity is unknown can be archived through nativethread/archiveonly after the operator manually confirms that no other Codex process owns it. The plugin first performs a fresh process-local status check, and then the thread disappears from the catalog. That check cannot guarantee that another App Server process is not using the thread. OpenClaw refuses to archive active and error rows, and paired-node archiving is unavailable until the node bridge can take full ownership of the streamed thread lifecycle. Unarchiving in a native Codex client makes the thread eligible to reappear. lastReadAt/markedUnreadAt: read-state timestamps set on the server side bysessions.patch { unread }.unread: falserecords a read operation (setslastReadAtand clearsmarkedUnreadAt), whileunread: truemarks the session as unread until the next read occurs. Session rows expose a derivedunreadboolean: either explicitly marked unread, or read before the most recent activity. Sessions that were never read remainunread: false, so existing installations do not show as unread after an upgrade.lastActivityAt: records when the last agent run that qualifies as unread-worthy activity completed (user, channel, and cron runs). Heartbeat and internal-event turns, along with metadata patches, do not update this field;updatedAtdoes not count as an activity signal.sessionFile: a legacy marker kept for migration and archive compatibility; the active runtime uses SQLite identity instead.chatType:direct | group | roomprovider,subject,room,space,displayName: metadata for group and channel labeling- Toggles:
thinkingLevel,verboseLevel,reasoningLevel,elevatedLevel,sendPolicy(per-session override) - Model selection:
providerOverride,modelOverride,authProfileOverride - Token counters (best-effort and provider-dependent):
inputTokens,outputTokens,totalTokens,contextTokens compactionCount: tracks how many times auto-compaction completed for this session keymemoryFlushAt/memoryFlushCompactionCount: the timestamp and compaction count from the last memory flush before compaction
The Gateway holds authority: it may rewrite or rehydrate entries while sessions are running. For legacy file-backed installations, perform migration with openclaw doctor --session-sqlite import --session-sqlite-all-agents instead of editing sessions.json and expecting the runtime to continue reading that file.
Transcript event structure
The OpenClaw session accessor manages transcripts and makes them available to runtime code through identity-based helpers. The event stream is append-only:
- The session header comes first:
type: "session",id,cwd,timestamp, and optionallyparentSession. - After that, entries appear as
idcombined withparentId, forming a tree structure.
Notable entry types:
message: represents user, assistant, and toolResult messages.custom_message: a message injected by an extension that does enter the model context. It renders in the TUI whendisplay: trueis set and is fully hidden whendisplay: falseis set.custom: extension state that does not enter the model context. It persists extension state across reloads.compaction: a stored compaction summary containingfirstKeptEntryIdandtokensBefore.branch_summary: a stored summary created when navigating a tree branch.
OpenClaw deliberately avoids fixing up transcripts; the Gateway relies on SessionManager to read and write them.
Context windows vs tracked tokens
Two separate ideas exist:
- Model context window: a hard limit per model representing the tokens visible to the model. This value comes from the model catalog and can be overridden through configuration.
- Session store counters: rolling statistics written into the session row, used for
/statusand dashboards.contextTokensis a runtime estimate or reporting value, not a strict guarantee.
For additional details on limits, see /reference/token-use.
Compaction: what it is
Compaction takes older conversation content and condenses it into a stored compaction entry within the transcript, while keeping recent messages unchanged. After compaction completes, future turns see the compaction summary along with messages after firstKeptEntryId. Compaction is persistent, unlike session pruning, which is described in /concepts/session-pruning.
By default, embedded OpenClaw compaction uses the session thinking level. To apply a different thinking level for summary calls, set agents.defaults.compaction.thinkingLevel; the runtime clamps this value to each concrete compaction model or its fallback. Native Codex app-server compaction controls its own compact request and cannot accept a per-compaction thinking override, so OpenClaw issues a warning and leaves that setting to Codex.
After compaction, AGENTS.md section reinjection is opt-in through agents.defaults.compaction.postCompactionSections. Plugins can add extra prompt context using before_prompt_build.
Chunk boundaries and tool pairing
When splitting a lengthy transcript into compaction chunks, OpenClaw keeps assistant tool calls paired with their corresponding toolResult entries:
- If the token-share split would fall between a tool call and its result, OpenClaw moves the boundary to the assistant tool-call message instead of separating the pair.
- If a trailing tool-result block would push the chunk over the target, OpenClaw keeps that pending tool block and leaves the unsummarized tail intact.
- Aborted or error tool-call blocks do not hold a split open.
When auto-compaction happens
Two triggers exist in the embedded OpenClaw agent:
- Overflow recovery: when the model returns a context-overflow error (
request_too_large,context length exceeded,input exceeds the maximum number of tokens,input token count exceeds the maximum number of input tokens,input is too long for the model,ollama error: context length exceeded, or other provider-specific variants), OpenClaw compacts and then retries. If the provider reports the attempted token count, OpenClaw forwards that count into overflow-recovery compaction. If the provider confirms overflow but does not expose a parseable count, OpenClaw passes a minimally over-budget synthetic count to compaction engines and diagnostics. When overflow recovery still fails, OpenClaw shows explicit guidance and keeps the current session mapping instead of silently rotating to a new session ID. Retry the message, run/compact, or run/new. - Threshold maintenance: after a successful turn, when the current context exceeds the model window minus OpenClaw's built-in headroom for prompts and the next model output.
Two additional guards run outside these triggers:
- Preflight local compaction: set
agents.defaults.compaction.maxActiveTranscriptBytes(in bytes or as a string like"20mb") to trigger local compaction before opening the next run once the active transcript reaches that size. This guard controls size for local reopen cost, not raw archival. Normal semantic compaction still runs, andtruncateAfterCompactionis required so the compacted summary becomes a new successor transcript. - Mid-turn precheck: set
agents.defaults.compaction.midTurnPrecheck.enabled: true(defaultfalse) to add a tool-loop guard. After a tool result is appended and before the next model call, OpenClaw estimates prompt pressure using the same preflight budget logic used at turn start. If context no longer fits, the guard does not compact inline. Instead, it raises a structured mid-turn precheck signal, stops the current prompt submission, and lets the outer run loop use the existing recovery path (truncate oversized tool results when that suffices, or trigger the configured compaction mode and retry). Works with bothdefaultandsafeguardcompaction modes, including provider-backed safeguard compaction. Independent ofmaxActiveTranscriptBytes: the byte-size guard runs before a turn opens, while mid-turn precheck runs later, after new tool results are appended.
Compaction settings
{
agents: {
defaults: {
compaction: {
enabled: true,
keepRecentTokens: 20000,
},
},
},
}
OpenClaw enforces a built-in reserve for embedded runs and caps it against the active model context window so it cannot consume the entire prompt budget. This keeps small-context local models from entering compaction from the first token while leaving enough headroom for multi-turn housekeeping like the memory flush.
Manual /compact respects an explicit agents.defaults.compaction.keepRecentTokens and preserves the runtime's recent-tail cut point. Without an explicit keep budget, manual compaction acts as a hard checkpoint, and rebuilt context starts from the new summary.
When truncateAfterCompaction is enabled, OpenClaw rotates the active transcript to a compacted successor after compaction. Branch and restore checkpoint actions use that compacted successor; legacy pre-compaction checkpoint files remain readable while referenced.
Pluggable compaction providers
Plugins register a compaction provider through registerCompactionProvider() on the plugin API. When agents.defaults.compaction.provider is set to a registered provider ID, the safeguard extension delegates summarization to that provider instead of the built-in summarizeInStages pipeline.
provider: the ID of a registered compaction provider plugin. Leave unset for default LLM summarization. Setting aproviderforcesmode: "safeguard".- Providers receive the same compaction instructions and identifier-preservation policy as the built-in path, and the safeguard still preserves recent-turn and split-turn suffix context after provider output.
- Built-in safeguard summarization re-distills prior summaries with new messages instead of preserving the full previous summary verbatim.
- Safeguard mode enables summary quality audits by default; set
qualityGuard.enabled: falseto skip retry-on-malformed-output behavior. - If the provider fails or returns an empty result, OpenClaw falls back to built-in LLM summarization automatically. Abort or timeout signals that the caller explicitly triggered are re-thrown, not swallowed, so cancellation is always respected.
src/plugins/compaction-provider.ts, src/agents/agent-hooks/compaction-safeguard.ts.
User-visible surfaces
/statuswithin any chat sessionopenclaw status(CLI)openclaw sessions/openclaw sessions --json- Gateway logs (
pnpm gateway:watchoropenclaw logs --follow):embedded run auto-compaction start+complete - Verbose mode:
🧹 Auto-compaction completealong with the compaction count
Silent housekeeping (NO_REPLY)
For background tasks where intermediate output should be hidden from the user, OpenClaw provides "silent" turns.
- The assistant begins its response with the exact silent token
NO_REPLY/no_reply, signaling "do not send a reply to the user." OpenClaw removes or suppresses this token in the delivery layer. - Suppression of the exact silent token is case-insensitive: both
NO_REPLYandno_replyare recognized when the entire payload consists solely of the silent token. - Starting with
2026.1.10, OpenClaw also suppresses draft or typing streaming when a partial chunk starts withNO_REPLY, preventing partial output from leaking during silent operations. - This feature is intended exclusively for true background or no-delivery turns, not as a substitute for ordinary user-facing requests.
Pre-compaction memory flush
Before automatic compaction occurs, OpenClaw can execute a silent agentic turn that writes durable state to disk (for instance, memory/YYYY-MM-DD.md in the agent workspace), ensuring compaction does not remove critical context. It monitors session context usage, and when that usage crosses a soft threshold below the compaction threshold, it issues a silent "write memory now" directive using the exact silent token NO_REPLY / no_reply, keeping the user unaware of the operation.
Configuration (agents.defaults.compaction.memoryFlush), full reference at /gateway/config-agents:
| Key | Default | Notes |
|---|---|---|
enabled | true | |
model | unset | Exact provider or model override for the flush turn only, for example ollama/qwen3:8b |
softThresholdTokens | 4000 | Gap below the compaction threshold that triggers a flush |
forceFlushTranscriptBytes | unset (disabled) | Forces a flush once the transcript file reaches this byte size (or a string like "2mb"), even if token counters are stale; 0 disables |
Notes:
- The built-in prompt and system prompt include a
NO_REPLYhint to suppress delivery. - When
modelis set, the flush turn uses that model without inheriting the active session's fallback chain, so local-only housekeeping does not silently fall back to a paid conversation model on failure. - The flush runs once per compaction cycle (tracked in the session row).
- The flush runs only for embedded OpenClaw sessions; CLI backends and heartbeat turns skip it.
- The flush is skipped when the session workspace is read-only (
workspaceAccess: "ro"or"none"). - See Memory for the workspace file layout and write patterns.
OpenClaw exposes a session_before_compact hook in the extension API, but the flush logic above lives on the Gateway side (src/auto-reply/reply/memory-flush.ts, src/auto-reply/reply/agent-runner-memory.ts), not on that hook.
Troubleshooting checklist
- Session key wrong? Start with /concepts/session and confirm the
sessionKeyin/status. - Store vs transcript mismatch? Confirm the Gateway host and the store path from
openclaw status. - Compaction spam? Check the model's context window (too small forces frequent compaction) and tool-result bloat (tune session pruning).
- Every prompt seems to overflow on a small local model? Confirm the provider reports the correct model context window. OpenClaw can cap the effective reserve only when that window is known.
- Silent turns leaking? Confirm the reply starts with the exact silent token
NO_REPLY(case-insensitive) and you are on a build that includes the streaming-suppression fix (2026.1.10+).