TL;DR Link to heading

My OpenClaw gateway kept calling an LLM provider I had retired. The new primary in openclaw.json was ignored because the long-running “heartbeat” session pinned the model selection at session creation time, both in the session index and in a model_change event at the top of the session transcript. Resetting the session let the global config take effect.

Motivation Link to heading

I noticed my self-hosted OpenClaw gateway was still hitting an LLM provider I had switched away from days earlier. openclaw.json had a different primary model and no fallbacks, but the metered usage disagreed. Each “heartbeat” tick cost close to ten cents and shipped 370k cached input tokens to the wrong provider.

What I expected Link to heading

When I edit agents.defaults.model.primary in openclaw.json and reload, the next agent turn should use the new primary. Sessions created earlier should re-resolve the primary on each tick rather than hold onto whatever was current at creation time.

What was actually happening Link to heading

OpenClaw stores per-session-key state in ~/.openclaw/agents/main/sessions/sessions.json, where each entry caches the resolved modelProvider and model. The first event written to the session transcript (<id>.jsonl) is also a model_change event with the same provider and model. Both were stamped weeks ago, when the old primary was still in place.

On every heartbeat tick the runtime:

  1. Looks up the session for agent:main:main in sessions.json.
  2. Replays the existing transcript as context, including the leading model_change event.
  3. Uses the cached model from that event for the LLM call.

Nothing in this path re-reads openclaw.json. Edits only apply to new sessions.

The bonus problem hiding underneath: the heartbeat session had compactionCount: 0 and a 2.27 MB transcript covering nine days of HEARTBEAT_OK ticks. Each tick replayed the whole thing on top of a 47k-character system prompt. Cache hits softened the bill, but I was paying for context I did not need on a session that should have stayed light.

The fix Link to heading

Two-step reset on the host:

# 1. Move the pinned transcript aside so the next tick has to start fresh
ts=$(date -u +%Y-%m-%dT%H-%M-%SZ)
mv ~/.openclaw/agents/main/sessions/<session-id>.jsonl{,.reset.$ts}

# 2. Drop the cached model from the session index
python3 - <<'EOF'
import json, shutil, time
p = "/home/ubuntu/.openclaw/agents/main/sessions/sessions.json"
shutil.copy(p, f"{p}.bak.{int(time.time())}")
d = json.load(open(p))
del d["agent:main:main"]
json.dump(d, open(p, "w"), indent=2)
EOF

The next heartbeat created a fresh session against the current config. Bootstrap dropped from 370k tokens per tick to about 12k. The provider switched.

How to spot this Link to heading

If you suspect a long-running session is pinned to a model you thought you removed, two signals together are diagnostic:

  • The session’s index entry shows a modelProvider/model that does not match your current global default.
  • The session transcript opens with a model_change event for the same stale provider.

To find runaway sessions, count provider mentions in each transcript:

for f in ~/.openclaw/agents/main/sessions/*.jsonl; do
  c=$(grep -c '"provider":"old-provider"' "$f")
  [ "$c" -gt 0 ] && echo "$c $f"
done | sort -rn | head

Cross-reference the session id against sessions.json to find which session key it is bound to, then reset.

The takeaway Link to heading

Editing a config file does not mean every running session honours the change. Anything keyed by session, conversation, or workspace can cache config values at creation time and outlive later edits. When I change a default in a system with long-running sessions now, I look for the existing sessions and either reset them or accept that the change is forward-only.

Postscript: the upgrade rabbit hole Link to heading

After the fix I tried to upgrade OpenClaw to the latest stable release. That cost me another forty minutes:

  • The upgrade triggered exhaustive plugin runtime-deps staging on the first message. Around 480 MB of cached chunks, ten-plus minutes of 100% CPU with no log output.
  • Telegram outbound failed because the new HTTP client bypassed /etc/hosts and tried IPv6 first on a VM with no IPv6 routing.

The IPv6 issue had a clean fix:

echo -e 'net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
net.ipv6.conf.lo.disable_ipv6=1' | sudo tee /etc/sysctl.d/99-disable-ipv6.conf
sudo sysctl -p /etc/sysctl.d/99-disable-ipv6.conf

The runtime-deps staging did not. OpenClaw’s GitHub had a fresh “blocker” issue covering the same regression, with several users pinning to the previous release I had skipped. I rolled back to that release and stopped trying to be the integration test for a stable tag the maintainers had not stabilised yet.

Update (May 2026): the fix is in flight Link to heading

A week on, the issue I filed (#74284) is still open, but a pull request targeting it has appeared: #74932. It is mergeable and green on CI since 2026-04-30. ClawSweeper’s review traced the same code path I described above and confirmed the PR fixes that seam. The review also notes that the unbounded transcript growth I observed is a separate concern, so the file-size growth still needs handling even after the PR lands.

While waiting for the merge I revisited my own config and found a few agents.defaults.heartbeat knobs I had been ignoring. Bumping heartbeat.every from the 30-minute default to four hours and bounding activeHours to 08:00–22:00 cut tick frequency from 48 to 8 per day. Pinning heartbeat.target and heartbeat.to to a specific channel keeps replies and any compaction notices from defaulting to “last channel that was active”, which can route them somewhere unintended.

Two agents.defaults.compaction knobs are also worth flipping. truncateAfterCompaction defaults to false, which means compaction can run and start serving from a summary while the on-disk JSONL keeps every original entry. The 1.3 MB file I saw was partly a measurement artefact of that default. notifyUser posts brief 🧹 Compacting context... and 🧹 Compaction complete messages in chat when compaction starts and finishes, which is useful as a diagnostic and is also the reason target matters.

Two corrections to my earlier diagnosis. First, the transcript had compactionCount: 1, not 0. Compaction had already run once. After that, the runtime was serving from the summary while the JSONL on disk kept growing, which is the documented behaviour with truncateAfterCompaction: false. The cost driver was less “transcript replay every tick” than I first thought, and more “frequency × bootstrap × proactive work”. Second, at a 30-minute interval, OpenAI’s prompt cache (roughly five-minute TTL) was not saving me as much as I had assumed. Each tick was paying nearly full input cost on the bootstrap. The savings come from doing it eight times a day instead of forty-eight, not from caching.

  • openclaw/openclaw#74284: I filed this as a follow-up covering the heartbeat path specifically. Still open, but PR #74932 targets it with closing syntax and is awaiting maintainer review.
  • openclaw/openclaw#51677: sessions.json caches stale model after config change. Closed as implemented in v2026.4.22, but the fix targets the reply path.
  • openclaw/openclaw#67078: /new initialised fresh Telegram DM session on the wrong model. Closed as implemented in v2026.4.20, fixing the /new//reset reset path.

Further reading Link to heading