Session Management Deep Dive: Store, Lifecycle, and Compaction
This page explains how the Gateway owns session state, including the two persistence layers and compaction internals. It is intended for developers and operators who need to understand session handling in remote and local modes.
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 is the sole owner of session state from start to finish. Session lists and token counts are retrieved from the Gateway by the various UIs, including the macOS app, web Control UI, and TUI. When operating in remote mode, the per-agent SQLite database resides on the remote host, meaning local Mac state checks won't mirror what the Gateway is actively using.
For introductory material, start with these: Session management, Compaction, Memory overview, Memory search, Session pruning, and Transcript hygiene. The complete configuration reference lives at Agent config.
Two persistence layers
- Session rows (per-agent SQLite) - a key/value map
sessionKey -> SessionEntry. This mutable runtime state is held by the Gateway. It tracks metadata including the current session id, last activity, toggles, and token counters. - Transcript events (per-agent SQLite) - append-only with a tree structure where entries carry
idandparentId. Conversations, tool calls, and compaction summaries are stored here, and model context for subsequent turns is rebuilt from this data. Compaction checkpoints serve as metadata layered over the compacted successor transcript; a fresh compaction never creates a second.checkpoint.*.jsonlcopy.
Some older installations might still have sessions.json files located in the agent sessions/ directory. Those files should be treated as legacy session-row migration inputs or as explicit targets for offline maintenance. During Gateway startup and via openclaw doctor --fix import, legacy rows and transcript history are automatically hot-loaded into the per-agent SQLite store. For explicit inspection or validation evidence, run openclaw doctor --session-sqlite inspect --session-sqlite-all-agents and then proceed with the Doctor migration sequence. Should a migration fail after legacy transcript artifacts have been archived, the Doctor recovery mode from that sequence is the way forward. Recovery relies on migration manifests, restores only the affected archived support artifacts, prepares a sanitized GitHub issue report on request, and never makes active runtime read JSONL files again.
Gateway history readers avoid materializing the full transcript unless arbitrary historical access is required. Bounded tail reads from SQLite handle first-page history, embedded chat history, restart recovery, and token/usage checks. Full transcript scans are routed through the async transcript index and shared across concurrent readers.
On-disk locations
Per agent, on the Gateway host (resolved through 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
Automatic maintenance for SQLite session rows, SQLite transcript rows, archive artifacts, and trajectory sidecars is governed by session.maintenance:
| Key | Default | Notes |
|---|---|---|
mode | "enforce" | or "warn" (report only, no mutation) |
pruneAfter | "30d" | stale-entry age cutoff |
maxEntries | 500 | cap on total live session rows when protection permits |
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, 0, or "0" disables |
highWaterBytes | 80% of maxDiskBytes | target after cleanup; zero-resolving values use the default, and negatives are invalid |
When a reset occurs, the live sessionKey -> sessionId mapping advances, yet the prior SQLite session, transcript, trajectory, and search rows remain intact. That history stays searchable under the same session key, while ordinary entry and session lists present only the new live mapping. The disk budget bounds retained reset history, not resetArchiveRetention, which only ages archive artifacts. Explicit deletion takes a different path: before the deleted session's rows are removed, a compressed transcript archive is written and verified (*.jsonl.deleted.<timestamp>.zst when zstd is available).
Physical bytes drive maxDiskBytes enforcement: the per-agent SQLite main file, its -wal file, and counted files within the agent sessions directory. Row JSON sizes are never estimated, and logical row sizes are never subtracted from that total.
Gateway model-run probe sessions, identified by keys matching agent:*:explicit:model-run-<uuid>, receive a separate, fixed 24h retention. This pruning operates under pressure gating, running only when session-entry maintenance or cap pressure is reached, and exclusively before the global stale-entry cleanup or cap step. Other explicit sessions are not subject to this retention.
Once combined physical usage surpasses maxDiskBytes, mode: "enforce" first reclaims checkpointable database space, then deletes the oldest retained reset or delete archives. If usage remains above highWaterBytes, it iterates historical SQLite sessions by sessions.updated_at, starting with the oldest. Historical means the session id is not referenced by a live session entry, a route target, or an admitted or in-flight run. For each victim, cleanup writes, fsyncs, and reads back the compressed archive before a write transaction removes the session row along with its transcript, trajectory, active, index, and FTS projections. Sessions holding trajectory events but no transcript events are included. At deletion time, cleanup rechecks route, entry, and admission references, remeasures physical usage after each archive or session victim, and halts at highWaterBytes.
Committed writes and deletions initially land in the WAL. Cleanup checkpoints it so the WAL can shrink immediately, then applies incremental vacuum to return eligible free tail pages from the main file; pages not yet reclaimable remain in the main file and thus stay counted on the next physical measurement. mode: "warn" reports the current physical overage without checkpointing, writing an archive, or deleting rows.
Maintenance can be run on demand:
openclaw sessions cleanup --dry-run
openclaw sessions cleanup --enforce
Every live session row is counted by maxEntries. Archived or pinned sessions, active or admitted work, model-locked sessions, and durable external conversation pointers like group sessions and thread-scoped chat sessions are never automatic eviction targets, yet they still consume the cap. Cleanup removes the oldest unprotected rows until the total hits maxEntries or no eligible victims remain. Consequently, the store can stay above the cap when protected rows alone exceed it or active work temporarily blocks eviction. 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.
--dry-run shows what the total-row cap would allow and lists the unprotected rows that would fit under it; --enforce executes that cleanup right away but leaves protection untouched. To shrink protected history, you can unarchive, unpin, wait for ongoing work to complete, or remove sessions you no longer need.
Standard Gateway writes go through the session accessor, which routes per-agent SQLite changes through the runtime writer queue. Runtime code should rely on the accessor helpers in src/config/sessions/session-accessor.ts; the older sessions.json helpers exist for migrations and offline maintenance. When a Gateway is available, non-dry-run openclaw sessions cleanup and openclaw agents delete hand store changes to the Gateway so cleanup joins the same writer queue; --store <path> serves as the explicit offline repair route for a chosen legacy store and always operates locally (as does --dry-run). For production-scale stores, maxEntries cleanup runs in batches, so the total count may temporarily exceed the cap until the next high-water cleanup brings it down. Reads never trim or cap entries during Gateway startup, only writes or openclaw sessions cleanup --enforce do, and the latter enforces the cap immediately and removes old unreferenced legacy transcript, checkpoint, and trajectory artifacts even without a disk budget set.
OpenClaw no longer generates automatic sessions.json.bak.* rotation backups during Gateway writes. The current schema rejects the legacy session.maintenance.rotateBytes key, and openclaw doctor --fix strips it from older configurations.
Transcript changes flow through the session accessor and the SQLite writer queue. Every change checks the active run's durable writer claim inside its commit transaction, so a superseded run cannot write to the transcript.
Downgrading After The SQLite Flip
Bring back archived legacy transcript artifacts before launching an older file-backed OpenClaw version:
openclaw doctor --session-sqlite restore --session-sqlite-all-agents
The migration keeps legacy sessions.json files around for support and
rollback, but hot transcript JSONL files imported into SQLite get renamed
into session-sqlite-import-archive/. Older file-backed runtimes follow
the sessionFile paths in sessions.json, so those artifacts must be restored
before startup. Restore relies on migration manifests, moves only recorded archived
artifacts whose original paths are absent, and leaves the SQLite database
untouched for forward recovery.
Sessions created after the SQLite flip exist only in SQLite and won't show up for an older file-backed runtime. If you re-upgrade after a downgrade, run the Doctor inspection and validation sequence again so OpenClaw can check restored legacy artifacts before importing.
Cron sessions and run logs
Isolated cron runs generate their own session entries/transcripts with separate retention:
cron.sessionRetention(default"24h") removes old isolated cron run sessions from the store;falseor a zero duration like"0h"turns it off.- Run history keeps the newest 2000 terminal rows per cron job. Lost rows still have their 24-hour cleanup window.
When cron force-creates a new isolated run session, it cleans 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/auth overrides, but drops ambient conversation context (channel/group routing, send/queue policy, elevation, origin, ACP runtime binding) so a fresh isolated run cannot pick up stale delivery or runtime authority from an older run.
Session keys (sessionKey)
A sessionKey tells you which conversation bucket you're in (routing + 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 points to a current sessionId (the SQLite transcript identity that continues the conversation). Decision logic lives in initSessionState() in src/auto-reply/reply/session.ts.
- Reset (
/new,/reset) makes a newsessionIdfor thatsessionKey. - No automatic reset is the default. The current
sessionIdcontinues while compaction keeps the active model context bounded. - Daily reset (
session.reset.mode: "daily") makes a newsessionIdon the next message after the configured local-hour boundary (session.reset.atHour, default4). - Idle expiry (
session.reset.mode: "idle"withsession.reset.idleMinutes, or legacysession.idleMinutes) makes a newsessionIdwhen a message arrives after the idle window. If daily and idle are both set, whichever expires first wins. - Control UI reconnect resume keeps the currently visible session for one reconnect send when the Gateway gets the matching
sessionIdfrom an operator UI client. This is a one-shot signal; ordinary stale sends still make a newsessionId. - System events (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may change the session row but never extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built.
- Parent fork policy uses OpenClaw's active branch when creating a thread or subagent fork. If that branch is too large (over a fixed internal cap, currently 100K tokens), OpenClaw starts the child with isolated context instead of 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 machinery 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 passed explicitly, and marks the childforkedFromParentwith fresh token counters.
Session store schema
The runtime store persists SessionEntry values in a per-agent SQLite database. The value type is defined as SessionEntry in src/config/sessions.ts. Key fields, though not an exhaustive list, include:
sessionId: the current transcript identifier used to reference SQLite transcript rowssessionStartedAt: the start timestamp for the activesessionId; daily reset freshness relies on this value. Legacy rows may obtain it from the JSONL session header.lastInteractionAt: the timestamp of the last genuine user or channel interaction; idle reset freshness depends on this so that heartbeat, cron, and exec events do not prevent session expiry. Legacy rows lacking this field default to the recovered session start time.updatedAt: the timestamp of the most recent store-row mutation, relevant for listing, pruning, and bookkeeping, but not the authority for daily or idle freshness.archivedAt: an optional archive timestamp. Archived sessions remain in the store with their transcript intact and are omitted from standard active listings.pinnedAt: an optional pin timestamp. Active pinned sessions are ordered ahead of unpinned ones; archiving a session removes its pin.- Codex thread interop: both fields conform to the Codex thread-management format, where the
archived/pinnedbooleans on the wire are always generated from the timestamp and stamped server-side, aligning with Codexthreads.archived_atsemantics and camelCase serialization. OpenClaw timestamps use epoch milliseconds, whereas Codex uses epoch seconds, so bridges perform conversion at thecodexplugin seam. Codex lacks a pin API at present (onlythread/archive/thread/unarchiveexist); pinned state remains OpenClaw-side until such an API appears, at which point the compatible shape allows bound sessions to exchange pin state mechanically. - Codex supervision lists only native threads that are not archived. A Gateway-local
idleornotLoadedthread whose activity is unknown can be archived through nativethread/archiveonly after the operator explicitly verifies that no other Codex process owns it; the plugin first performs a fresh process-local status check, and the thread then vanishes from the catalog. That check cannot establish that another App Server process is not using the thread. OpenClaw declines to archive active and error rows, and paired-node archiving is unavailable until the node bridge can manage the entire streamed thread lifecycle. Unarchiving within a native Codex client makes the thread eligible to reappear. lastReadAt/markedUnreadAt: read-state timestamps stamped server-side bysessions.patch { unread }, whereunread: falserecords a read (settinglastReadAtand clearingmarkedUnreadAt), andunread: truemarks the session unread until the next read. Session rows expose a derivedunreadboolean: explicitly flagged unread, or read before the most recent activity. Sessions never marked read remainunread: false, so existing installs do not become active on upgrade.lastActivityAt: the timestamp of the last completed agent run that qualifies as unread-worthy activity (user, channel, and cron runs). Heartbeat and internal-event turns, along with metadata patches, do not alter it;updatedAtis not treated as an activity signal.sessionFile: a legacy marker retained for migration and archive compatibility; the active runtime uses SQLite identity.chatType:direct | group | roomprovider,subject,room,space,displayName: group and channel labeling metadata- 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: the number of times auto-compaction has completed for this session keymemoryFlushAt/memoryFlushCompactionCount: the timestamp and compaction count of the last memory flush before compaction
The Gateway holds final authority: it can rewrite or rehydrate entries as sessions execute. For legacy file-backed installs, use openclaw doctor --session-sqlite import --session-sqlite-all-agents for migration rather than editing sessions.json and expecting the runtime to keep reading that file.
Transcript event structure
Transcripts are handled by the OpenClaw session accessor and made available to runtime code via identity-based helpers. The event stream is append-only:
- First entry: session header -
type: "session",id,cwd,timestamp, optionalparentSession. - Then: entries with
id+parentId(tree structure).
Notable entry types:
message: user/assistant/toolResult messagescustom_message: extension-injected message that does enter model context (rendered in the TUI whendisplay: true, hidden entirely whendisplay: false)custom: extension state that does not enter model context (for persisting extension state across reloads)compaction: persisted compaction summary withfirstKeptEntryIdandtokensBeforebranch_summary: persisted summary when navigating a tree branch
OpenClaw intentionally does not "fix up" transcripts; the Gateway uses SessionManager to read/write them.
Context windows vs tracked tokens
Two different concepts:
- Model context window: hard cap per model (tokens visible to the model). Comes from the model catalog and can be overridden via config.
- Session store counters: rolling stats written into the session row (used for
/statusand dashboards).contextTokensis a runtime estimate/reporting value - do not treat it as a strict guarantee.
More on limits: /reference/token-use.
Compaction: what it is
Compaction summarizes older conversation into a persisted compaction entry in the transcript and keeps recent messages intact. After compaction, future turns see the compaction summary plus messages after firstKeptEntryId. Compaction is persistent, unlike session pruning - see /concepts/session-pruning.
Embedded OpenClaw compaction inherits the session thinking level by default. Set agents.defaults.compaction.thinkingLevel to use a separate level for summary calls; the runtime clamps it to each concrete compaction model or fallback. Native Codex app-server compaction owns its compact request and cannot accept a per-compaction thinking override, so OpenClaw warns and leaves that setting to Codex.
AGENTS.md section reinjection after compaction remains opt-in via agents.defaults.compaction.postCompactionSections. Plugins can add other prompt context through before_prompt_build.
Chunk boundaries and tool pairing
When splitting a long transcript into compaction chunks, OpenClaw keeps assistant tool calls paired with their matching toolResult entries:
- If the token-share split would land between a tool call and its result, OpenClaw shifts the boundary to the assistant tool-call message instead of separating the pair.
- If a trailing tool-result block would otherwise push the chunk over target, OpenClaw preserves that pending tool block and keeps the unsummarized tail intact.
- Aborted/error tool-call blocks do not hold a pending split open.
When auto-compaction happens
Two triggers in the embedded OpenClaw agent:
- Overflow recovery: 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, and other provider-shaped variants) - compact, then retry. When the provider reports the attempted token count, OpenClaw forwards that observed count into overflow-recovery compaction; if the provider confirms overflow but exposes no parseable count, OpenClaw passes a minimally over-budget synthetic count to compaction engines and diagnostics. If overflow recovery still fails, OpenClaw surfaces explicit guidance and preserves the current session mapping instead of silently rotating to a fresh 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 two triggers:
- Preflight local compaction: set
agents.defaults.compaction.maxActiveTranscriptBytesto a positive byte threshold (bytes or a string like"20mb") to trigger local compaction before opening the next run once the active transcript reaches that size. Normal semantic compaction still runs. For Codex app-server sessions, the same threshold caps native rollout transcripts and oversized native threads restart fresh. Unset or0disables the guard. - 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 - 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 is enough, 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, 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 whole prompt budget. This keeps small-context local models from entering compaction from the first token while leaving enough headroom for multi-turn housekeeping such as the memory flush.
Set enabled: false to disable threshold-driven auto-compaction inside the embedded agent runtime. OpenClaw's preflight and overflow-recovery compaction paths remain available, and manual /compact continues to work.
Manual /compact uses agents.defaults.compaction.keepRecentTokens (default: 20000) and keeps that recent-tail cut point.
OpenClaw adopts an explicit successor identity returned by a context engine. The built-in SQLite compactor keeps the current session identity. Branch/restore checkpoint actions use a returned successor when present; legacy pre-compaction checkpoint files remain readable while referenced.
Pluggable compaction providers
Plugins register a compaction provider via 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 identifier of a registered compaction provider plugin. When left empty, the default LLM summarization applies. Assigning aprovidermakesmode: "safeguard"mandatory.- Both the built-in route and providers receive identical compaction instructions and an identifier-preservation policy, and the safeguard continues to retain recent-turn and split-turn suffix context following provider output.
- Rather than keeping the entire previous summary verbatim, the built-in safeguard summarization re-distills earlier summaries together with new messages.
- Safeguard mode turns on built-in summary quality audits by default. After final budgeting, the retained generated body must include the required headings, and the exact artifact slated for persistence must keep pending asks and exact identifiers. Corrective attempts are confined to
qualityGuard.maxRetries; when they are exhausted or a corrective generation fails, the operation cancels before append and the original transcript stays authoritative. SetqualityGuard.enabled: falseto bypass this behavior. Configured compaction-provider output is excluded from the built-in audit loop. - Should the provider fail or produce an empty result, OpenClaw automatically switches to built-in LLM summarization. Abort/timeout signals explicitly triggered by the caller are re-thrown rather than swallowed, so cancellation is always honored.
Source: src/plugins/compaction-provider.ts, src/agents/agent-hooks/compaction-safeguard.ts.
User-visible surfaces
/statusin 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 completeplus the compaction count
Silent housekeeping (NO_REPLY)
For background tasks where the user should not see intermediate output, OpenClaw supports "silent" turns.
- To signal "do not deliver a reply to the user," the assistant begins its output with the exact silent token
NO_REPLY/no_reply. OpenClaw strips/suppresses this in the delivery layer. - Suppression of the exact silent token is case-insensitive: when the entire payload is just the silent token, both
NO_REPLYandno_replyqualify. - Starting with
2026.1.10, OpenClaw also suppresses draft/typing streaming when a partial chunk begins withNO_REPLY, so mid-turn partial output does not leak during silent operations. - This applies only to true background/no-delivery turns, not as a shortcut for ordinary actionable user requests.
Pre-compaction memory flush
Before auto-compaction occurs, OpenClaw can run a silent agentic turn that writes durable state to disk (for instance memory/YYYY-MM-DD.md in the agent workspace) so compaction cannot erase critical context. It monitors session context usage, and once usage crosses a soft threshold below the compaction threshold, it sends a silent "write memory now" directive using the exact silent token NO_REPLY / no_reply so the user sees nothing.
Config (agents.defaults.compaction.memoryFlush), full reference at /gateway/config-agents:
| Key | Default | Notes |
|---|---|---|
enabled | true | |
model | unset | exact provider/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) | force a flush once active transcript history reaches this estimated byte size (or string like "2mb"), even if token counters are stale; 0 disables |
Notes:
- A
NO_REPLYhint is included in the built-in prompt and system prompt 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. - Per compaction cycle, the flush runs once (tracked in the session row).
- Only embedded OpenClaw sessions get the flush; CLI backends and heartbeat turns skip it.
- The flush is skipped when the session workspace is read-only (
workspaceAccess: "ro"or"none"). - For the workspace file layout and write patterns, see Memory.
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+).