Openclaw · EN · Part 3
Ch 09 Session Management And State
09-ch-09-Session-Management-and-State.md
第9章 Session Management and State
Managing dozens of concurrent conversations across twenty different messaging channels presents a fundamental architectural challenge. Each message from WhatsApp, Telegram, Discord, or Slack needs to find its way back to the right conversational context, with the right history, the right configuration, and the right state. OpenClaw solves this with a sophisticated session model that balances simplicity, performance, and extensibility.
The Session Model
At its core, an OpenClaw session is an isolated conversation state container. Every message routed through the Gateway resolves to a session key, which maps to a session containing the conversation history, configuration overrides, and runtime state. This design enables powerful features: per-conversation model overrides, distinct context windows for different chats, and isolated state that prevents cross-contamination between conversations.
// src/config/sessions/types.ts:75-110
export type SessionEntry = {
sessionId: string;
updatedAt: number;
sessionFile?: string;
spawnedBy?: string;
parentSessionKey?: string;
forkedFromParent?: boolean;
spawnDepth?: number;
subagentRole?: "orchestrator" | "leaf";
chatType?: SessionChatType; // "direct" | "group" | "channel" | "unknown"
groupActivation?: "mention" | "always";
sendPolicy?: "allow" | "deny";
queueMode?: "steer" | "followup" | "collect" | "steer-backlog" | "steer+backlog" | "queue" | "interrupt";
thinkingLevel?: string;
fastMode?: boolean;
modelOverride?: string;
providerOverride?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
contextTokens?: number;
compactionCount?: number;
// ... additional fields for auth profiles, TTS, exec, etc.
};
This type tells you everything about OpenClaw's session philosophy. Sessions aren't just message containers—they're configuration scopes, state carriers, and lifecycle managers all in one.
Main Sessions vs Group Sessions
The session model distinguishes between direct messages (DMs) and group conversations through the chatType field, which OpenClaw derives from session keys using pattern matching:
// src/sessions/session-chat-type.ts:32-62
export function deriveSessionChatType(sessionKey: string | undefined | null): SessionKeyChatType {
const raw = (sessionKey ?? "").trim().toLowerCase();
if (!raw) {
return "unknown";
}
const scoped = parseAgentSessionKey(raw)?.rest ?? raw;
const tokens = new Set(scoped.split(":").filter(Boolean));
if (tokens.has("group")) {
return "group";
}
if (tokens.has("channel")) {
return "channel";
}
if (tokens.has("direct") || tokens.has("dm")) {
return "direct";
}
// ... legacy pattern matching for WhatsApp groups, Discord channels, etc.
return "unknown";
}
Group sessions have special semantics. Unlike DM sessions where every message triggers a response, group sessions respect the groupActivation setting. When set to "mention", OpenClaw only responds when explicitly mentioned—a critical default for avoiding spammy behavior in crowded channels.
Activation Modes
OpenClaw supports multiple activation strategies through groupActivation:
| Mode | Behavior | Use Case |
|---|---|---|
"mention" | Respond only when mentioned | Busy group chats, default for groups |
"always" | Respond to every message | Private channels, dedicated bot channels |
This is per-session configuration, not global. You can have OpenClaw in "always" mode for a dedicated #ai-helper channel while keeping "mention" mode for your team's general Slack.
Queue Modes
The queueMode field controls how OpenClaw handles rapid message bursts—common when users send multiple messages before the first response completes:
| Mode | Behavior |
|---|---|
"steer" | Process latest message, discard earlier queue |
"followup" | Treat queued messages as follow-ups to current request |
"collect" | Batch all queued messages into single context |
"steer-backlog" | Process latest, keep earlier for context |
"steer+backlog" | Hybrid with backlog awareness |
"queue" | Traditional FIFO queue processing |
"interrupt" | Abort current run, start new with latest message |
The "interrupt" mode is particularly interesting—it implements an interruption protocol where the current agent run receives an abort signal, and the queued message immediately starts a fresh turn. This enables responsive conversational steering without waiting for long-running tool calls to complete.
🤿 深入底层:Queue modes operate at the auto-reply layer (
src/auto-reply/reply/queue/state.ts), not the agent layer. The queue exists before context assembly, which means queued messages never consume tokens until they're actually processed. This design prevents token waste on messages that might be discarded by"steer"mode.
Session Creation, Resolution, and Destruction
Sessions aren't explicitly created—they're resolved on-demand. When a message arrives, OpenClaw:
- Computes a session key from the channel, account, and peer identifiers
- Looks up the session key in the session store (
sessions.json) - Creates a new
SessionEntryif none exists - Resolves the physical session file path (a
.jsonltranscript) - Loads the conversation history for context assembly
// src/config/sessions/session-file.ts:5-50
export async function resolveAndPersistSessionFile(params: {
sessionId: string;
sessionKey: string;
sessionStore: Record<string, SessionEntry>;
storePath: string;
sessionEntry?: SessionEntry;
agentId?: string;
sessionsDir?: string;
fallbackSessionFile?: string;
activeSessionKey?: string;
}): Promise<{ sessionFile: string; sessionEntry: SessionEntry }> {
const { sessionId, sessionKey, sessionStore, storePath } = params;
const baseEntry = params.sessionEntry ??
sessionStore[sessionKey] ?? { sessionId, updatedAt: Date.now() };
const fallbackSessionFile = params.fallbackSessionFile?.trim();
const entryForResolve =
!baseEntry.sessionFile && fallbackSessionFile
? { ...baseEntry, sessionFile: fallbackSessionFile }
: baseEntry;
const sessionFile = resolveSessionFilePath(sessionId, entryForResolve, {
agentId: params.agentId,
sessionsDir: params.sessionsDir,
});
const persistedEntry: SessionEntry = {
...baseEntry,
sessionId,
updatedAt: Date.now(),
sessionFile,
};
// ... persist to store
return { sessionFile, sessionEntry: persistedEntry };
}
Sessions are destroyed through archiving. When you reset a session with /new, delete it, or when maintenance prunes old sessions, the transcript file isn't deleted—it's renamed with an archive suffix:
a1b2c3d4.jsonl → a1b2c3d4.jsonl.reset.2026-04-04T12-30-45Z
This preserves conversation history for forensic analysis while cleaning the active session namespace.
Session Lifecycle
The complete session lifecycle spans creation, binding, persistence, activation, and eventual cleanup. Understanding this flow is essential for building custom session behaviors or debugging state issues.
Rendering diagram...
Session Binding
Binding is the process of associating a session key with its runtime state. This happens in the auto-reply layer before agent invocation:
// Conceptual binding flow (simplified from src/auto-reply/reply/session.ts)
async function bindSession(params: {
channel: string;
accountId: string;
peerId: string;
sessionStore: Record<string, SessionEntry>;
}): Promise<BoundSession> {
const sessionKey = computeSessionKey(params.channel, params.accountId, params.peerId);
const existingEntry = sessionStore[sessionKey];
if (existingEntry) {
return {
entry: existingEntry,
sessionKey,
sessionFile: resolveSessionFilePath(existingEntry.sessionId, existingEntry),
isNew: false,
};
}
const sessionId = crypto.randomUUID();
const newEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
chatType: deriveSessionChatType(sessionKey),
groupActivation: "mention", // default for groups
queueMode: "steer-backlog", // default queue mode
};
sessionStore[sessionKey] = newEntry;
return {
entry: newEntry,
sessionKey,
sessionFile: resolveSessionFilePath(sessionId, newEntry),
isNew: true,
};
}
The isNew flag determines whether OpenClaw sends a system introduction message—first contact with a new conversation gets extra context about who OpenClaw is and what it can do.
State Persistence
Session state lives in two places:
sessions.json: Lightweight metadata store (one file per agent)<sessionId>.jsonl: Full conversation transcript (one file per session)
The transcript file uses JSONL format—one JSON object per line. This design enables efficient append-only writes and trivial crash recovery. If the Gateway crashes mid-turn, the transcript is always in a valid state (partial lines are ignored).
// Transcript structure (conceptual)
{ "type": "header", "sessionId": "a1b2c3d4", "createdAt": 1723456789000 }
{ "type": "message", "role": "user", "content": "Hello", "id": "msg-1", "parentId": null, "timestamp": 1723456789001 }
{ "type": "message", "role": "assistant", "content": "Hi there!", "id": "msg-2", "parentId": "msg-1", "timestamp": 1723456789500 }
{ "type": "model_change", "model": "claude-sonnet-4.6", "id": "model-1", "parentId": "msg-2", "timestamp": 1723456790000 }
{ "type": "compaction", "firstKeptEntryId": "msg-2", "summary": "Previous conversation summarized...", "id": "compact-1", "parentId": "msg-2", "timestamp": 1723456800000 }
Notice the tree structure: every entry has a parentId forming a directed acyclic graph. This enables branching conversations—alternative responses, "what if" explorations, and recursive tool calls all exist in the same transcript tree.
Cleanup
Session cleanup is automatic and conservative. The reaper runs periodically (src/cron/session-reaper.ts) but only removes:
- Sessions with
sendPolicy: "deny"(explicitly blocked) - Sessions older than a configured threshold
- Sessions that have exceeded disk budget limits
Active sessions are never touched. A session is "active" if it has been updated within the idle TTL (default 24 hours for ACP sessions, configurable for main sessions).
⚠️ 避坑指南:The reaper respects write locks. If a session file has an active lock (
.lockfile alongside the transcript), cleanup is deferred until the lock is released. This prevents mid-turn cleanup from deleting the transcript you're actively writing to. Always use write locks for custom session manipulation.
File-Based Storage Approach
OpenClaw's file-based session storage is intentional. No database. No Redis. Just the filesystem. Why?
- Portability: Copy
~/.openclawto another machine, everything works - Debuggability: Open
.jsonlfiles in any text editor, read conversation history - Durability: No database corruption scenarios, no replication lag
- Simplicity: No migration scripts, no schema drift, no connection pooling
The tradeoff is scalability. File I/O becomes a bottleneck at thousands of concurrent sessions. But OpenClaw is designed for personal AI assistance—one user, dozens of active conversations, not an enterprise SaaS platform.
Context and Memory
Sessions provide the container, but context provides the intelligence to each agent turn. The context window is what the model actually sees—your message, the conversation history, system prompts, tool definitions, and any injected workspace files.
Context Windows
OpenClaw's context assembly is a sophisticated pipeline (src/agents/pi-embedded-runner/context-engine-helpers.ts). The process:
- Load transcript entries from the session file
- Stop at cutoff if compaction occurred (don't include summarized messages)
- Inject system prompt (agent instructions)
- Add tool schemas (available tools for this turn)
- Include workspace files (if sandboxed to a project)
- Apply truncation if approaching token limits
The totalTokensFresh field on SessionEntry indicates whether the token count reflects a fresh snapshot. This matters for UI displays—stale token counts show "unknown" rather than potentially misleading numbers.
// src/config/sessions/types.ts:313-330
export function resolveFreshSessionTotalTokens(
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh"> | null,
): number | undefined {
const total = entry?.totalTokens;
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) {
return undefined;
}
if (entry?.totalTokensFresh === false) {
return undefined;
}
return total;
}
export function isSessionTotalTokensFresh(
entry?: Pick<SessionEntry, "totalTokens" | "totalTokensFresh"> | null,
): boolean {
return resolveFreshSessionTotalTokens(entry) !== undefined;
}
This freshness guard prevents UI components from displaying misleading token utilization metrics after compaction or context rebuild.
Message History
The transcript is the source of truth for message history. When OpenClaw assembles context, it walks the transcript tree from root to leaf, collecting message entries:
// Simplified context assembly
function buildContext(sessionFile: string, cutoffId?: string): Message[] {
const entries = parseTranscript(sessionFile);
const messages: Message[] = [];
for (const entry of entries) {
if (entry.type === "compaction" && entry.firstKeptEntryId) {
// Stop at compaction boundary
if (cutoffId === entry.firstKeptEntryId) break;
continue;
}
if (entry.type === "message") {
messages.push({
role: entry.role,
content: entry.content,
});
}
}
return messages;
}
After compaction, the summarized messages are physically removed from the file (via truncateSessionAfterCompaction), but the compaction entry itself preserves the firstKeptEntryId pointer so context assembly knows where the unsummarized tail begins.
Memory Plugin Interaction
The memory plugin (extensions/memory-*) extends session context by querying a vector store for semantically relevant facts. This happens during context assembly, not before:
- User message arrives
- Session binds, loads transcript
- Memory plugin queries vector store with message embedding
- Relevant facts injected as system context
- Agent turn proceeds with enriched context
This separation means memory is additive—sessions work fine without memory, but memory makes them smarter. The memoryFlushAt field on SessionEntry tracks when memory was last flushed, enabling incremental updates rather than full re-indexs.
History vs Context Limits Trade-offs
Here's the critical tradeoff: longer history = smarter but slower and more expensive. OpenClaw manages this through compaction—the process of summarizing old messages and keeping only the summary in the active context.
// src/agents/pi-embedded-runner/session-truncation.ts:33-76
export async function truncateSessionAfterCompaction(params: {
sessionFile: string;
archivePath?: string;
}): Promise<TruncationResult> {
const { sessionFile } = params;
const sm = SessionManager.open(sessionFile);
// Find the latest compaction entry
let latestCompactionIdx = -1;
for (let i = branch.length - 1; i >= 0; i--) {
if (branch[i].type === "compaction") {
latestCompactionIdx = i;
break;
}
}
if (latestCompactionIdx < 0) {
return { truncated: false, entriesRemoved: 0, reason: "no compaction" };
}
const compactionEntry = branch[latestCompactionIdx];
const { firstKeptEntryId } = compactionEntry;
// Collect summarized entry IDs (everything before firstKeptEntryId)
const summarizedBranchIds = new Set<string>();
for (let i = 0; i < latestCompactionIdx; i++) {
if (firstKeptEntryId && branch[i].id === firstKeptEntryId) {
break;
}
summarizedBranchIds.add(branch[i].id);
}
// Remove only message-type entries that were actually summarized
// Preserve all non-message state (model_change, thinking_level_change, etc.)
// ... re-parent entries to keep tree connected
// Rewrite file atomically
await fs.writeFile(sessionFile, truncatedContent);
return { truncated: true, entriesRemoved: removedIds.size };
}
The truncation is surgical—only message entries are removed. All session state (model changes, thinking level adjustments, labels, branch summaries) is preserved even if it sits in the summarized portion. This prevents losing important configuration history when compaction occurs.
☕ 茶水间时间:Why remove old messages instead of keeping them "just in case"? Because context window costs are real. A 200K token context with Sonnet 4.6 costs significantly more than a 4K token context, even if you never read the old messages. Compaction is a cost optimization that trades perfect recall for 95% accuracy at 10% of the cost. Most conversations don't need verbatim history from three weeks ago—they need the gist.
Advanced Session Patterns
The session model's flexibility enables powerful patterns beyond basic chatbot interactions. Multi-user sessions, session migration, and custom storage backends all build on the same foundation.
Multi-User Sessions
OpenClaw supports "parent-child" session relationships through spawnedBy and parentSessionKey. When a tool creates a sub-agent for a focused task, that sub-agent gets its own session with spawnDepth: 1:
// Parent session (depth 0)
{
sessionId: "parent-uuid",
spawnDepth: 0,
subagentRole: "orchestrator",
}
// Child session spawned by tool (depth 1)
{
sessionId: "child-uuid",
spawnedBy: "parent-uuid",
spawnDepth: 1,
subagentRole: "leaf",
parentSessionKey: "telegram:123456789",
}
The subagentRole field determines tool policy—orchestrators can spawn more sub-agents, while leaf agents are restricted to direct tool calls. This creates controlled hierarchies instead of unbounded agent spawning.
Parent-child relationships also enable "background" ACP sessions (src/acp/session-interaction-mode.ts). A parent-owned oneshot session runs delegated work but reports back through the parent's notification channel instead of speaking directly to the user-facing channel.
Session Migration
Sessions can migrate between channels through the sessionKey abstraction. The same session ID maps to different physical transcript files based on the agent:
~/.openclaw/agents/default/sessions/a1b2c3d4.jsonl
~/.openclaw/agents/research-agent/sessions/a1b2c3d4.jsonl
Each agent has its own sessions directory. When you switch agents mid-conversation, OpenClaw creates a new session entry with the same sessionId but different sessionFile path. This preserves continuity while enabling agent-specific histories.
The resolveSessionFilePath function handles migration from legacy absolute paths to modern relative paths:
// src/config/sessions/paths.ts:262-277
export function resolveSessionFilePath(
sessionId: string,
entry?: { sessionFile?: string },
opts?: SessionFilePathOptions,
): string {
const sessionsDir = resolveSessionsDir(opts);
const candidate = entry?.sessionFile?.trim();
if (candidate) {
try {
return resolvePathWithinSessionsDir(sessionsDir, candidate, { agentId: opts?.agentId });
} catch {
// Keep handlers alive when persisted metadata is stale/corrupt.
}
}
return resolveSessionTranscriptPathInDir(sessionId, sessionsDir);
}
The try-catch with fallback is intentional—legacy sessions might have absolute paths that no longer resolve. Instead of failing, we fall back to the canonical path based on sessionId.
Custom Storage Backends
While OpenClaw uses file-based storage by default, the session store interface is abstract. You can implement custom storage by replacing the session resolution logic:
// Custom session storage backend (moderate complexity)
import type { SessionEntry } from "@openclaw/config/sessions";
interface CustomSessionBackend {
get(sessionKey: string): Promise<SessionEntry | undefined>;
set(sessionKey: string, entry: SessionEntry): Promise<void>;
list(agentId: string): Promise<Record<string, SessionEntry>>;
}
class RedisSessionBackend implements CustomSessionBackend {
private client: Redis;
private keyPrefix: string;
constructor(redisClient: Redis, keyPrefix = "openclaw:session:") {
this.client = redisClient;
this.keyPrefix = keyPrefix;
}
async get(sessionKey: string): Promise<SessionEntry | undefined> {
const data = await this.client.get(this.keyPrefix + sessionKey);
return data ? JSON.parse(data) : undefined;
}
async set(sessionKey: string, entry: SessionEntry): Promise<void> {
await this.client.set(
this.keyPrefix + sessionKey,
JSON.stringify(entry),
{ EX: 60 * 60 * 24 * 7 } // 7 day expiration
);
}
async list(agentId: string): Promise<Record<string, SessionEntry>> {
const keys = await this.client.keys(this.keyPrefix + agentId + ":*");
const entries: Record<string, SessionEntry> = {};
for (const key of keys) {
const sessionKey = key.slice(this.keyPrefix.length);
const entry = await this.get(sessionKey);
if (entry) {
entries[sessionKey] = entry;
}
}
return entries;
}
}
// Integration point: hook into session resolution
// This would be wired through the Plugin SDK's session-store-runtime surface
This Redis backend trades durability for speed—sessions load in microseconds instead of milliseconds, but you lose the "copy ~/.openclaw to migrate" story. Choose your tradeoffs based on deployment constraints.
⚠️ 避坑指南:Custom storage backends must respect the write lock protocol (
src/agents/session-write-lock.ts). Before modifying a session, create<sessionFile>.lockwith your process ID. After writing, remove the lock. If a lock exists and is stale (older than a timeout), you may steal it—but log loudly. Missing lock handling causes concurrent writes to corrupt sessions.
Architectural Considerations
When designing custom session behaviors, consider these principles:
- Session keys are opaque identifiers—don't parse them for logic, use
SessionEntryfields - Transcripts are append-only—never rewrite history except through compaction
- Write locks are mandatory—concurrent session modification corrupts data
- Metadata in
sessions.json, data in.jsonl—keep the separation clear - Compaction is not deletion—archived transcripts are still accessible
The session model is simple at the surface—key maps to entry, entry maps to file—but the depth enables sophisticated behaviors. Multi-user collaboration tools, cross-channel conversation threading, and hierarchical agent workflows all build on this foundation.
Sessions state needs to be persisted. Let's explore OpenClaw's storage and data management approach.
Chapter Exercises
-
Trace a session from creation to destruction: Start a fresh conversation with OpenClaw, observe the
sessions.jsonand.jsonlfile creation, send/new, and verify the archive suffix is applied. Trace the complete lifecycle through the code paths insrc/config/sessions/. -
Implement a session with custom context retention: Create a plugin that uses the
session-store-runtimesurface to inject custom metadata into sessions. Implement a "topic memory" feature that persists key topics across compaction boundaries. -
Build a session analytics tool: Write a CLI command that analyzes all sessions for an agent, reporting on token usage, compaction frequency, and most active conversation partners. Use the patterns in
src/commands/sessions-cleanup.tsas a starting point.