> ## Documentation Index
> Fetch the complete documentation index at: https://docs.wednesdayai.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Session Management

# Session Management

WednesdayAI treats **one direct-chat session per agent** as primary. Direct chats collapse to `agent:<agentId>:<mainKey>` (default `main`), while group/channel chats get their own keys. `session.mainKey` is honored.

Use `session.dmScope` to control how **direct messages** are grouped:

* `main` (default): all DMs share the main session for continuity.
* `per-peer`: isolate by sender id across channels.
* `per-channel-peer`: isolate by channel + sender (recommended for multi-user inboxes).
* `per-account-channel-peer`: isolate by account + channel + sender (recommended for multi-account inboxes).
  Use `session.identityLinks` to map provider-prefixed peer ids to a canonical identity so the same person shares a DM session across channels when using `per-peer`, `per-channel-peer`, or `per-account-channel-peer`.

## Secure DM mode (recommended for multi-user setups)

> **Security Warning:** If your agent can receive DMs from **multiple people**, you should strongly consider enabling secure DM mode. Without it, all users share the same conversation context, which can leak private information between users.

**Example of the problem with default settings:**

* Alice (`<SENDER_A>`) messages your agent about a private topic (for example, a medical appointment)
* Bob (`<SENDER_B>`) messages your agent asking "What were we talking about?"
* Because both DMs share the same session, the model may answer Bob using Alice's prior context.

**The fix:** Set `dmScope` to isolate sessions per user:

```json5 theme={"dark"}
// ~/.openclaw/openclaw.json
{
  session: {
    // Secure DM mode: isolate DM context per channel + sender.
    dmScope: "per-channel-peer",
  },
}
```

**When to enable this:**

* You have pairing approvals for more than one sender
* You use a DM allowlist with multiple entries
* You set `dmPolicy: "open"`
* Multiple phone numbers or accounts can message your agent

Notes:

* Default is `dmScope: "main"` for continuity (all DMs share the main session). This is fine for single-user setups.
* Local CLI onboarding writes `session.dmScope: "per-channel-peer"` by default when unset (existing explicit values are preserved).
* For multi-account inboxes on the same channel, prefer `per-account-channel-peer`.
* If the same person contacts you on multiple channels, use `session.identityLinks` to collapse their DM sessions into one canonical identity.
* You can verify your DM settings with `openclaw security audit` (see [security](/cli/security)).
* Session isolation and [Workspace Lanes](/concepts/workspace-lanes) solve different problems. `dmScope` separates conversation history; workspace lanes separate user-specific workspace files for multi-user-capable personas.

## Gateway is the source of truth

All session state is **owned by the gateway**. UI clients (macOS app, WebChat, etc.) must query the gateway for session lists and token counts instead of reading local files.

* In **remote mode**, the session store you care about lives on the remote gateway host, not your Mac.
* Token counts shown in UIs come from the gateway’s store fields (`inputTokens`, `outputTokens`, `totalTokens`, `contextTokens`). Clients do not parse JSONL transcripts to “fix up” totals.

## Where state lives

* On the **gateway host**:
  * Store file: `~/.openclaw/agents/<agentId>/sessions/sessions.json` (per agent).
* Transcripts: `~/.openclaw/agents/<agentId>/sessions/<SessionId>.jsonl` (Telegram topic sessions use `.../<SessionId>-topic-<threadId>.jsonl`).
* The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
* Group entries may include `displayName`, `channel`, `subject`, `room`, and `space` to label sessions in UIs.
* Session entries include `origin` metadata (label + routing hints) so UIs can explain where a session came from.
* WednesdayAI does **not** read legacy Pi/Tau session folders.

## Maintenance

WednesdayAI applies session-store maintenance to keep `sessions.json` and transcript artifacts bounded over time.

### Defaults

* `session.maintenance.mode`: `warn`
* `session.maintenance.pruneAfter`: `30d`
* `session.maintenance.maxEntries`: `500`
* `session.maintenance.rotateBytes`: `10mb`
* `session.maintenance.resetArchiveRetention`: defaults to `pruneAfter` (`30d`)
* `session.maintenance.maxDiskBytes`: unset (disabled)
* `session.maintenance.highWaterBytes`: defaults to `80%` of `maxDiskBytes` when budgeting is enabled

### How it works

Maintenance runs during session-store writes, and you can trigger it on demand with `openclaw sessions cleanup`.

* `mode: "warn"`: reports what would be evicted but does not mutate entries/transcripts.
* `mode: "enforce"`: applies cleanup in this order:
  1. prune stale entries older than `pruneAfter`
  2. cap entry count to `maxEntries` (oldest first)
  3. archive transcript files for removed entries that are no longer referenced
  4. purge old `*.deleted.<timestamp>` and `*.reset.<timestamp>` archives by retention policy
  5. rotate `sessions.json` when it exceeds `rotateBytes`
  6. if `maxDiskBytes` is set, enforce disk budget toward `highWaterBytes` (oldest artifacts first, then oldest sessions)

### Performance caveat for large stores

Large session stores are common in high-volume setups. Maintenance work is write-path work, so very large stores can increase write latency.

What increases cost most:

* very high `session.maintenance.maxEntries` values
* long `pruneAfter` windows that keep stale entries around
* many transcript/archive artifacts in `~/.openclaw/agents/<agentId>/sessions/`
* enabling disk budgets (`maxDiskBytes`) without reasonable pruning/cap limits

What to do:

* use `mode: "enforce"` in production so growth is bounded automatically
* set both time and count limits (`pruneAfter` + `maxEntries`), not just one
* set `maxDiskBytes` + `highWaterBytes` for hard upper bounds in large deployments
* keep `highWaterBytes` meaningfully below `maxDiskBytes` (default is 80%)
* run `openclaw sessions cleanup --dry-run --json` after config changes to verify projected impact before enforcing
* for frequent active sessions, pass `--active-key` when running manual cleanup

### Customize examples

Use a conservative enforce policy:

```json5 theme={"dark"}
{
  session: {
    maintenance: {
      mode: "enforce",
      pruneAfter: "45d",
      maxEntries: 800,
      rotateBytes: "20mb",
      resetArchiveRetention: "14d",
    },
  },
}
```

Enable a hard disk budget for the sessions directory:

```json5 theme={"dark"}
{
  session: {
    maintenance: {
      mode: "enforce",
      maxDiskBytes: "1gb",
      highWaterBytes: "800mb",
    },
  },
}
```

Tune for larger installs (example):

```json5 theme={"dark"}
{
  session: {
    maintenance: {
      mode: "enforce",
      pruneAfter: "14d",
      maxEntries: 2000,
      rotateBytes: "25mb",
      maxDiskBytes: "2gb",
      highWaterBytes: "1.6gb",
    },
  },
}
```

Preview or force maintenance from CLI:

```bash theme={"dark"}
openclaw sessions cleanup --dry-run
openclaw sessions cleanup --enforce
```

## Plugin correlation and privacy

Plugins can receive resolved runtime identity and turn correlation fields so they can log, dedupe, and run background analysis without parsing session keys or reading raw identity configuration.

For operators, the important boundary is:

* `session.identityLinks` remains configuration used by core identity resolution. Plugin contexts expose resolved fields such as `canonicalIdentity` and `senderIsOwner`; they do not expose raw `identityLinks`.
* `channelConversationId` identifies the transport conversation or thread. Legacy plugin fields named `conversationId` refer to this channel identity.
* `providerConversationId` is an opaque provider/cache correlation id derived by WednesdayAI for model calls. It is not a session storage column and should be stored only as an opaque value.
* `turn.turnId` identifies one user turn and is the same string value as `runId` in the v1 plugin contract.
* `sessionId` and `sessionKey` may be absent during very early `message_received` hooks. WednesdayAI omits them when no real session exists instead of fabricating a session value.

If a plugin captures storage append events, user and assistant entries for the same exchange share one `turnId`. Dedupe append fan-out with the turn id plus the entry id, cursor entry id, or raw entry hash.

This feature does not make untrusted plugins safe. Plugins still run in-process with gateway trust, but the public SDK now gives them resolved identity and opaque correlation fields instead of requiring them to read private config or reverse-engineer session key formats.

## External session storage

By default, sessions live in `~/.openclaw/agents/<agentId>/sessions/` on the gateway host as local
JSONL transcript files plus the `sessions.json` store index.

For durable or shared storage, configure `session.storage` to use SQLite or Postgres, optionally
with a Redis hot cache. See the `session.storage` section in
[Gateway configuration](/gateway/configuration) and the full
[Configuration Reference](/gateway/configuration-reference#session).

### Configured-backend-only invariant

WednesdayAI treats the configured `session.storage.backend` as the single source of truth for session
transcript data:

* **`fs-jsonl`** (legacy default): transcript entries are read and written through `*.jsonl` files.
* **`sqlite`**: transcript entries are read and written through the SQLite `ctx_session_entries` table.
* **`postgres`**: transcript entries are read and written through the Postgres `ctx_session_entries` table.

The JSONL path is reachable **only** when `backend === "fs-jsonl"`. When a database backend is active,
core paths such as `chat.history`, `sessions.preview`, session-list title/preview derivation, and
sub-agent announce output read from the configured database, not from JSONL files.

What this means in practice:

* Switching from JSONL to SQLite/Postgres does not require a manual transcript migration; the store
  metadata file is rebuilt on demand and the database becomes the source of truth for content.
* If you have existing `.jsonl` files and start with a database backend, the gateway may backfill
  those files into the database on first startup, but subsequent reads always come from the database.
  Disable the backfill with `session.storage.migration.validateOnStart: false` if you want a clean
  cut-over.
* Running `openclaw sessions cleanup` still bounds `sessions.json` and any JSONL archives for the
  `fs-jsonl` backend; for database backends it does not mutate the database content directly.

### Sub-agent and announce output

Sub-agent runs (`sessions_spawn`) and their announce step use the same transcript read path as the
rest of the gateway. Before this invariant, a database backend could leave sub-agent assistant output
in Postgres while the announce reader looked at JSONL, producing a truncated or empty
"(no output)" result. With the configured-backend-only invariant, the announce reader consults the
same backend that the run wrote to, so sub-agent output renders correctly regardless of whether the
backend is JSONL, SQLite, or Postgres.

## Session pruning

WednesdayAI trims **old tool results** from the in-memory context right before LLM calls by default.
This does **not** rewrite JSONL history. See [/concepts/session-pruning](/concepts/session-pruning).

## Pre-compaction memory flush

When a session nears auto-compaction, WednesdayAI can run a **silent memory flush**
turn that reminds the model to write durable notes to disk. This only runs when
the workspace is writable. See [Memory](/concepts/memory) and
[Compaction](/concepts/compaction).

## Mapping transports → session keys

* Direct chats follow `session.dmScope` (default `main`).
  * `main`: `agent:<agentId>:<mainKey>` (continuity across devices/channels).
    * Multiple phone numbers and channels can map to the same agent main key; they act as transports into one conversation.
  * `per-peer`: `agent:<agentId>:dm:<peerId>`.
  * `per-channel-peer`: `agent:<agentId>:<channel>:dm:<peerId>`.
  * `per-account-channel-peer`: `agent:<agentId>:<channel>:<accountId>:dm:<peerId>` (accountId defaults to `default`).
  * If `session.identityLinks` matches a provider-prefixed peer id (for example `telegram:123`), the canonical key replaces `<peerId>` so the same person shares a session across channels.
* Group chats isolate state: `agent:<agentId>:<channel>:group:<id>` (rooms/channels use `agent:<agentId>:<channel>:channel:<id>`).
  * Telegram forum topics append `:topic:<threadId>` to the group id for isolation.
  * Legacy `group:<id>` keys are still recognized for migration.
* Inbound contexts may still use `group:<id>`; the channel is inferred from `Provider` and normalized to the canonical `agent:<agentId>:<channel>:group:<id>` form.
* Other sources:
  * Isolated cron assistant-task jobs: `cron:<job.id>`
  * Webhooks: `hook:<uuid>` (unless explicitly set by the hook)
  * Node runs: `node-<nodeId>`

## Lifecycle

* Reset policy: sessions are reused until they expire, and expiry is evaluated on the next inbound message.
* Daily reset: defaults to **4:00 AM local time on the gateway host**. A session is stale once its last update is earlier than the most recent daily reset time.
* Idle reset (optional): `idleMinutes` adds a sliding idle window. When both daily and idle resets are configured, **whichever expires first** forces a new session.
* Legacy idle-only: if you set `session.idleMinutes` without any `session.reset`/`resetByType` config, WednesdayAI stays in idle-only mode for backward compatibility.
* Per-type overrides (optional): `resetByType` lets you override the policy for `direct`, `group`, and `thread` sessions (thread = Slack/Discord threads, Telegram topics, Matrix threads when provided by the connector).
* Per-channel overrides (optional): `resetByChannel` overrides the reset policy for a channel (applies to all session types for that channel and takes precedence over `reset`/`resetByType`).
* Reset triggers: exact `/new` or `/reset` (plus any extras in `resetTriggers`) start a fresh session id and pass the remainder of the message through. `/new <model>` accepts a model alias, `provider/model`, or provider name (fuzzy match) to set the new session model. If `/new` or `/reset` is sent alone, WednesdayAI runs a short “hello” greeting turn to confirm the reset.
* Manual reset: delete specific keys from the store or remove the JSONL transcript; the next message recreates them.
* Isolated cron assistant-task jobs always mint a fresh `sessionId` per run (no idle reuse).
  Main-session cron jobs add a system event or wake-gate nudge to the main heartbeat path instead of creating a `cron:<job.id>` chat session.

## Send policy (optional)

Block delivery for specific session types without listing individual ids.

```json5 theme={"dark"}
{
  session: {
    sendPolicy: {
      rules: [
        { action: "deny", match: { channel: "discord", chatType: "group" } },
        { action: "deny", match: { keyPrefix: "cron:" } },
        // Match the raw session key (including the `agent:<id>:` prefix).
        { action: "deny", match: { rawKeyPrefix: "agent:main:discord:" } },
      ],
      default: "allow",
    },
  },
}
```

Runtime override (owner only):

* `/send on` → allow for this session
* `/send off` → deny for this session
* `/send inherit` → clear override and use config rules
  Send these as standalone messages so they register.

## Configuration (optional rename example)

```json5 theme={"dark"}
// ~/.openclaw/openclaw.json
{
  session: {
    scope: "per-sender", // keep group keys separate
    dmScope: "main", // DM continuity (set per-channel-peer/per-account-channel-peer for shared inboxes)
    identityLinks: {
      alice: ["telegram:123456789", "discord:987654321012345678"],
    },
    reset: {
      // Defaults: mode=daily, atHour=4 (gateway host local time).
      // If you also set idleMinutes, whichever expires first wins.
      mode: "daily",
      atHour: 4,
      idleMinutes: 120,
    },
    resetByType: {
      thread: { mode: "daily", atHour: 4 },
      direct: { mode: "idle", idleMinutes: 240 },
      group: { mode: "idle", idleMinutes: 120 },
    },
    resetByChannel: {
      discord: { mode: "idle", idleMinutes: 10080 },
    },
    resetTriggers: ["/new", "/reset"],
    store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
    mainKey: "main",
  },
}
```

## Inspecting

* `openclaw status` — shows store path and recent sessions.
* `openclaw sessions --json` — dumps every entry (filter with `--active <minutes>`).
* `openclaw gateway call sessions.list --params '{}'` — fetch sessions from the running gateway (use `--url`/`--token` for remote gateway access).
* Send `/status` as a standalone message in chat to see whether the agent is reachable, how much of the session context is used, current thinking/verbose toggles, and when your WhatsApp web creds were last refreshed (helps spot relink needs).
* Send `/context list` or `/context detail` to see what’s in the system prompt and injected workspace files (and the biggest context contributors).
* Send `/stop` (or standalone abort phrases like `stop`, `stop action`, `stop run`, `stop openclaw`) to abort the current run, clear queued followups for that session, and stop any sub-agent runs spawned from it (the reply includes the stopped count).
* Send `/compact` (optional instructions) as a standalone message to summarize older context and free up window space. See [/concepts/compaction](/concepts/compaction).
* JSONL transcripts can be opened directly to review full turns.

## Tips

* Keep the primary key dedicated to 1:1 traffic; let groups keep their own keys.
* When automating cleanup, delete individual keys instead of the whole store to preserve context elsewhere.

## Troubleshooting

**Two different people share context in DMs (privacy leak)**
`dmScope: "main"` is the default and routes all direct messages into one session. Set `dmScope: "per-channel-peer"` (or `"per-account-channel-peer"` for multi-account inboxes) so each sender gets an isolated context. Run `openclaw security audit` to confirm your DM isolation settings.

**Session does not reset at the expected daily time**
The daily reset uses the gateway host's local time (default 4:00 AM). If the gateway host is in a different timezone than expected, the reset fires at the wrong wall-clock time. Adjust `session.reset.atHour` to match your preferred local time, or set the gateway host's timezone. Also verify the gateway restarted after the config change.

**`sessions.json` grows very large and write latency increases**
Enable `mode: "enforce"` and set both `pruneAfter` and `maxEntries` limits. Running maintenance only in `"warn"` mode lets the store grow unbounded. Use `openclaw sessions cleanup --dry-run --json` to preview the impact before enforcing. For hard upper bounds, add `maxDiskBytes` + `highWaterBytes`.

## Related

* [Session pruning](/concepts/session-pruning)
* [Compaction](/concepts/compaction)
* [Gateway configuration](/gateway/configuration)

## Session origin metadata

Each session entry records where it came from (best-effort) in `origin`:

* `label`: human label (resolved from conversation label + group subject/channel)
* `provider`: normalized channel id (including extensions)
* `from`/`to`: raw routing ids from the inbound envelope
* `accountId`: provider account id (when multi-account)
* `threadId`: thread/topic id when the channel supports it
  The origin fields are populated for direct messages, channels, and groups. If a
  connector only updates delivery routing (for example, to keep a DM main session
  fresh), it should still provide inbound context so the session keeps its
  explainer metadata. Extensions can do this by sending `ConversationLabel`,
  `GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound
  context and calling `recordSessionMetaFromInbound` (or passing the same context
  to `updateLastRoute`).
