diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json
index 3cbe8b3ea31f..505e05641ab3 100644
--- a/apps/.i18n/native-source.json
+++ b/apps/.i18n/native-source.json
@@ -26483,7 +26483,7 @@
},
{
"kind": "ui-localized-call",
- "line": 122,
+ "line": 123,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Hide Sidebar",
"surface": "apple",
@@ -26491,7 +26491,7 @@
},
{
"kind": "ui-localized-call",
- "line": 209,
+ "line": 210,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Selected",
"surface": "apple",
@@ -26499,7 +26499,7 @@
},
{
"kind": "ui-localized-call",
- "line": 233,
+ "line": 234,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "More Agents",
"surface": "apple",
@@ -26507,7 +26507,7 @@
},
{
"kind": "ui-localized-call",
- "line": 336,
+ "line": 337,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Search sessions",
"surface": "apple",
@@ -26515,7 +26515,7 @@
},
{
"kind": "ui-localized-call",
- "line": 347,
+ "line": 348,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Clear session search",
"surface": "apple",
@@ -26523,7 +26523,7 @@
},
{
"kind": "ui-localized-call",
- "line": 375,
+ "line": 376,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Loading sessions",
"surface": "apple",
@@ -26531,7 +26531,7 @@
},
{
"kind": "ui-localized-call",
- "line": 383,
+ "line": 384,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "No recent sessions",
"surface": "apple",
@@ -26539,7 +26539,7 @@
},
{
"kind": "ui-localized-call",
- "line": 393,
+ "line": 394,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Recent",
"surface": "apple",
@@ -26547,7 +26547,7 @@
},
{
"kind": "ui-localized-call",
- "line": 409,
+ "line": 410,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "All Sessions…",
"surface": "apple",
@@ -26555,7 +26555,7 @@
},
{
"kind": "ui-localized-call",
- "line": 439,
+ "line": 440,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Edit Pages",
"surface": "apple",
@@ -26563,7 +26563,7 @@
},
{
"kind": "ui-localized-call",
- "line": 463,
+ "line": 464,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Home",
"surface": "apple",
@@ -26571,7 +26571,7 @@
},
{
"kind": "ui-localized-call",
- "line": 675,
+ "line": 677,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Unread",
"surface": "apple",
@@ -26579,7 +26579,7 @@
},
{
"kind": "ui-localized-call",
- "line": 701,
+ "line": 703,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Attention",
"surface": "apple",
@@ -26587,7 +26587,7 @@
},
{
"kind": "ui-localized-call",
- "line": 741,
+ "line": 744,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "New Chat",
"surface": "apple",
@@ -26595,7 +26595,7 @@
},
{
"kind": "ui-localized-call",
- "line": 770,
+ "line": 773,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Connection",
"surface": "apple",
@@ -26603,7 +26603,7 @@
},
{
"kind": "ui-localized-call",
- "line": 775,
+ "line": 778,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Online",
"surface": "apple",
@@ -26611,7 +26611,7 @@
},
{
"kind": "ui-localized-call",
- "line": 776,
+ "line": 779,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Connecting",
"surface": "apple",
@@ -26619,7 +26619,7 @@
},
{
"kind": "ui-localized-call",
- "line": 777,
+ "line": 780,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Needs attention",
"surface": "apple",
@@ -26627,7 +26627,7 @@
},
{
"kind": "ui-localized-call",
- "line": 778,
+ "line": 781,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Offline",
"surface": "apple",
@@ -26635,7 +26635,7 @@
},
{
"kind": "ui-call",
- "line": 877,
+ "line": 880,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Pinned pages stay in the sidebar. Home is always shown.",
"surface": "apple",
@@ -26643,7 +26643,7 @@
},
{
"kind": "ui-localized-call",
- "line": 881,
+ "line": 884,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Pages",
"surface": "apple",
@@ -26651,7 +26651,7 @@
},
{
"kind": "ui-localized-call",
- "line": 888,
+ "line": 891,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Done",
"surface": "apple",
@@ -26659,7 +26659,7 @@
},
{
"kind": "ui-localized-call",
- "line": 927,
+ "line": 930,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Pinned",
"surface": "apple",
@@ -26667,7 +26667,7 @@
},
{
"kind": "ui-localized-call",
- "line": 928,
+ "line": 931,
"path": "apps/ios/Sources/RootSidebar.swift",
"source": "Not pinned",
"surface": "apple",
@@ -26675,7 +26675,7 @@
},
{
"kind": "ui-named-argument",
- "line": 294,
+ "line": 295,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Overview",
"surface": "apple",
@@ -26683,7 +26683,7 @@
},
{
"kind": "ui-named-argument",
- "line": 322,
+ "line": 323,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Agents",
"surface": "apple",
@@ -26691,7 +26691,7 @@
},
{
"kind": "ui-named-argument",
- "line": 329,
+ "line": 330,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Instances",
"surface": "apple",
@@ -26699,7 +26699,7 @@
},
{
"kind": "ui-named-argument",
- "line": 340,
+ "line": 341,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Files",
"surface": "apple",
@@ -26707,7 +26707,7 @@
},
{
"kind": "ui-named-argument",
- "line": 347,
+ "line": 348,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Dreaming",
"surface": "apple",
@@ -26715,7 +26715,7 @@
},
{
"kind": "ui-named-argument",
- "line": 354,
+ "line": 355,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Usage",
"surface": "apple",
@@ -26723,7 +26723,7 @@
},
{
"kind": "ui-named-argument",
- "line": 361,
+ "line": 362,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Automations",
"surface": "apple",
@@ -26731,7 +26731,7 @@
},
{
"kind": "ui-localized-call",
- "line": 451,
+ "line": 452,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Hide Sidebar",
"surface": "apple",
@@ -26739,7 +26739,7 @@
},
{
"kind": "ui-localized-call",
- "line": 457,
+ "line": 458,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Show Sidebar",
"surface": "apple",
@@ -26747,7 +26747,7 @@
},
{
"kind": "ui-modifier",
- "line": 584,
+ "line": 585,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Close canvas",
"surface": "apple",
@@ -26755,7 +26755,7 @@
},
{
"kind": "conditional-branch",
- "line": 847,
+ "line": 848,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway needs attention",
"surface": "apple",
@@ -26763,7 +26763,7 @@
},
{
"kind": "conditional-branch",
- "line": 847,
+ "line": 848,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "OpenClaw iOS",
"surface": "apple",
@@ -26771,7 +26771,7 @@
},
{
"kind": "conditional-branch",
- "line": 883,
+ "line": 884,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Available",
"surface": "apple",
@@ -26779,7 +26779,7 @@
},
{
"kind": "conditional-branch",
- "line": 883,
+ "line": 884,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Gateway default",
"surface": "apple",
@@ -26787,7 +26787,7 @@
},
{
"kind": "conditional-branch",
- "line": 883,
+ "line": 884,
"path": "apps/ios/Sources/RootTabs.swift",
"source": "Routed on this phone",
"surface": "apple",
diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
index 0865961228db..df4c9393bc5b 100644
--- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
+++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt
@@ -386,6 +386,8 @@ enum class GatewayMethod(
CronGet("cron.get"),
CronList("cron.list"),
CronStatus("cron.status"),
+ CronScratchGet("cron.scratch.get"),
+ CronScratchSet("cron.scratch.set"),
CronAdd("cron.add"),
CronUpdate("cron.update"),
CronRemove("cron.remove"),
diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
index 8dab0267e307..c0df79359c29 100644
--- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
+++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift
@@ -13294,6 +13294,28 @@ public struct CronRunsParams: Codable, Sendable {
}
}
+public struct CronScratchGetResult: Codable, Sendable {
+ public let scratch: AnyCodable
+ public let currentrevision: Int
+ public let maxbytes: Int
+
+ public init(
+ scratch: AnyCodable,
+ currentrevision: Int,
+ maxbytes: Int)
+ {
+ self.scratch = scratch
+ self.currentrevision = currentrevision
+ self.maxbytes = maxbytes
+ }
+
+ private enum CodingKeys: String, CodingKey {
+ case scratch
+ case currentrevision = "currentRevision"
+ case maxbytes = "maxBytes"
+ }
+}
+
public struct CronRunLogEntry: Codable, Sendable {
public let ts: Int
public let jobid: String
diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256
index ac9bf08fa1c1..29217cd6ff62 100644
--- a/docs/.generated/plugin-sdk-api-baseline.sha256
+++ b/docs/.generated/plugin-sdk-api-baseline.sha256
@@ -103,7 +103,7 @@ aa2a56b4448c8ebdec9d06aac95d809995f533093d42fa32cd75e1d852967245 module/questio
bd2355e94248d21c252148085e5afa5db9a2f3f48f665a8b3e0fcf77326d9b6c module/reply-dispatch-runtime
ac2b199e95c5c8b1e2a65e62bd41d1b6322e531bca294ef4979a297a12640bce module/reply-history
f394fe4d5a7ed9e4d574063ae44e8d6af85c9a0e7d8b329f750ca16b0664325f module/reply-payload
-4e886f327f912688c338a1eccb208298987613f81325d4f999852b6925487eb2 module/reply-runtime
+554bbc681b17bf102a5e240e3f563c7d3c3f1dbab31f8fa3c9285f106c3e8111 module/reply-runtime
d78db621b8f4f0cc679cad2d5d21b6c95b5418c58611dd347cdf09049ed124f6 module/routing
ff6cca86f54f94f238205f5b122af36666314e0a380f3ec7f0ccb9ed9208df31 module/run-command
53b0295cec105696a1664c5c7f5576a7b55d197eb95dcd9185486f010bd53750 module/runtime
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index 2e84acd74c5a..d49ff687b741 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -280,7 +280,7 @@ Throws, timeouts, exhausted tool budgets, invalid results, and `nextCheck` witho
**Main session** jobs enqueue a system event into a cron-owned run lane and optionally wake the heartbeat (`--wake now` or `--wake next-heartbeat`). They can use the target main session's last delivery context for replies, but do not append routine cron turns to the human chat lane and do not extend daily/idle reset freshness for the target session. **Isolated** jobs run a dedicated agent turn with a fresh session. **Custom sessions** (`session:xxx`) persist context across runs, enabling workflows like daily standups that build on previous summaries.
- Main-session cron events are self-contained system-event reminders. They do not automatically include the default heartbeat prompt's "Read HEARTBEAT.md" instruction; say that explicitly in the cron event text if a reminder should consult `HEARTBEAT.md`.
+ Main-session cron events are self-contained system-event reminders. They do not automatically include the default heartbeat prompt or the heartbeat monitor scratch; say it explicitly in the cron event text if a reminder should consult that context.
diff --git a/docs/cli/cron.md b/docs/cli/cron.md
index 9bede2e8554c..a96e671ae391 100644
--- a/docs/cli/cron.md
+++ b/docs/cli/cron.md
@@ -306,6 +306,18 @@ openclaw cron runs --id --run-id
`cron runs` entries include delivery diagnostics with the intended cron target, the resolved target, message-tool sends, fallback use, and delivered state.
+Private per-job scratch (heartbeat checklists and similar monitor context):
+
+```bash
+openclaw cron scratch # print current scratch content
+openclaw cron scratch --json # scratch plus revision metadata
+openclaw cron scratch --set "text" # replace scratch with exact text
+openclaw cron scratch --file notes.md # replace scratch from a file (- for stdin)
+openclaw cron scratch --unset # remove the scratch row
+```
+
+Scratch is stored in the shared state database, capped at 256 KiB, and never included in `cron list`/`cron get`/`cron runs` output. Writes are compare-and-swap guarded against the revision read at command start; pass `--expected-revision ` to pin an explicit revision instead. See [Heartbeat](/gateway/heartbeat#monitor-scratch-optional) for how heartbeat monitors use scratch.
+
Agent and session retargeting:
```bash
diff --git a/docs/docs_map.md b/docs/docs_map.md
index 517e91548531..ca34be85ead7 100644
--- a/docs/docs_map.md
+++ b/docs/docs_map.md
@@ -3600,11 +3600,10 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H3: What each flag does
- H3: Per-channel vs per-account examples
- H3: Common patterns
- - H2: HEARTBEAT.md (optional)
+ - H2: Monitor scratch (optional)
- H3: tasks: blocks
- - H3: Can the agent update HEARTBEAT.md?
+ - H3: Can the agent update its scratch?
- H2: Manual wake (on-demand)
- - H2: Reasoning delivery (optional)
- H2: Cost awareness
- H2: Context overflow after heartbeat
- H2: Related
diff --git a/docs/gateway/config-agents.md b/docs/gateway/config-agents.md
index 5602a65e89b2..e2e58f2cc54e 100644
--- a/docs/gateway/config-agents.md
+++ b/docs/gateway/config-agents.md
@@ -63,7 +63,7 @@ Optional default skill allowlist for agents that do not set
### `agents.defaults.skipBootstrap`
-Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`).
+Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `BOOTSTRAP.md`).
```json5
{
@@ -73,7 +73,7 @@ Disables automatic creation of workspace bootstrap files (`AGENTS.md`, `SOUL.md`
### `agents.defaults.skipOptionalBootstrapFiles`
-Skips creation of selected optional workspace files while still writing required bootstrap files (`AGENTS.md`, `TOOLS.md`, `BOOTSTRAP.md`). Valid values: `SOUL.md`, `USER.md`, `HEARTBEAT.md`, and `IDENTITY.md`.
+Skips creation of selected optional workspace files while still writing required bootstrap files (`AGENTS.md`, `TOOLS.md`, `BOOTSTRAP.md`). Valid values: `SOUL.md`, `USER.md`, and `IDENTITY.md` (`HEARTBEAT.md` is accepted but a no-op since heartbeat context moved to cron monitor scratch).
```json5
{
@@ -553,14 +553,14 @@ Periodic heartbeat runs.
model: "openai/gpt-5.4-mini",
includeReasoning: false,
includeSystemPromptSection: true, // default: true; false omits the Heartbeat section from the system prompt
- lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
+ lightContext: false, // default: false; true skips workspace bootstrap files for heartbeat runs
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
skipWhenBusy: false, // default: false; true also waits for this agent's subagent/nested lanes
session: "main",
to: "+15555550123",
directPolicy: "allow", // allow (default) | block
target: "none", // default: none | options: last | whatsapp | telegram | discord | ...
- prompt: "Read HEARTBEAT.md if it exists...",
+ prompt: "Follow the heartbeat monitor scratch context...",
ackMaxChars: 300,
suppressToolErrorWarnings: false,
timeoutSeconds: 45,
@@ -571,11 +571,11 @@ Periodic heartbeat runs.
```
- `every`: duration string (ms/s/m/h). Default: `30m` (API-key auth) or `1h` (OAuth auth). Set to `0m` to disable.
-- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt and skips `HEARTBEAT.md` injection into bootstrap context. Default: `true`.
+- `includeSystemPromptSection`: when false, omits the Heartbeat section from the system prompt. Default: `true`.
- `suppressToolErrorWarnings`: when true, suppresses tool error warning payloads during heartbeat runs.
- `timeoutSeconds`: maximum time in seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
- `directPolicy`: direct/DM delivery policy. `allow` (default) permits direct-target delivery. `block` suppresses direct-target delivery and emits `reason=dm-blocked`.
-- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
+- `lightContext`: when true, heartbeat runs use lightweight bootstrap context and skip workspace bootstrap files. Monitor scratch is injected by the heartbeat runner either way.
- `isolatedSession`: when true, each heartbeat runs in a fresh session with no prior conversation history. Same isolation pattern as cron `sessionTarget: "isolated"`. Reduces per-heartbeat token cost from ~100K to ~2-5K tokens.
- `skipWhenBusy`: when true, heartbeat runs defer on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeats, even without this flag.
- Per-agent: set `agents.entries.*.heartbeat`. When any agent defines `heartbeat`, **only those agents** run heartbeats.
diff --git a/docs/gateway/heartbeat.md b/docs/gateway/heartbeat.md
index 1f49de07b94d..a81678bf9649 100644
--- a/docs/gateway/heartbeat.md
+++ b/docs/gateway/heartbeat.md
@@ -25,15 +25,14 @@ Troubleshooting: [Scheduled Tasks](/automation/cron-jobs#troubleshooting)
Leave heartbeats enabled (default is `30m`, or `1h` when Anthropic OAuth/token auth is configured, including Claude CLI reuse) or set your own cadence.
-
- Create a tiny `HEARTBEAT.md` checklist or `tasks:` block in the agent workspace.
+
+ Store a tiny checklist or `tasks:` block in the heartbeat monitor's scratch with `openclaw cron scratch --set "..."`.
`target: "none"` is the default; set `target: "last"` to route to the last contact.
- - Enable heartbeat reasoning delivery for transparency.
- - Use lightweight bootstrap context if heartbeat runs only need `HEARTBEAT.md`.
+ - Use lightweight bootstrap context if heartbeat runs only need the monitor scratch.
- Enable isolated sessions to avoid sending full conversation history each heartbeat.
- Restrict heartbeats to active hours (local time).
@@ -50,11 +49,9 @@ Example config:
every: "30m",
target: "last", // explicit delivery to last contact (default is "none")
directPolicy: "allow", // default: allow direct/DM targets; set "block" to suppress
- lightContext: true, // optional: only inject HEARTBEAT.md from bootstrap files
+ lightContext: true, // optional: skip workspace bootstrap files for heartbeat runs
isolatedSession: true, // optional: fresh session each run (no conversation history)
- skipWhenBusy: true, // optional: also defer when this agent's subagent or nested lanes are busy
// activeHours: { start: "08:00", end: "24:00" },
- // includeReasoning: true, // optional: send separate `Thinking` message too
},
},
},
@@ -64,12 +61,12 @@ Example config:
## Defaults
- Interval: `30m`. Applying Anthropic provider defaults bumps this to `1h` when the resolved auth mode is OAuth/token (including Claude CLI reuse), but only while `heartbeat.every` is unset. Set `agents.defaults.heartbeat.every` or per-agent `agents.entries.*.heartbeat.every`; use `0m` to disable.
-- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
+- Prompt body (configurable via `agents.defaults.heartbeat.prompt`): `Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
- Timeout: unset heartbeat turns use `agents.defaults.timeoutSeconds` when set. Otherwise, they use the heartbeat cadence capped at 600 seconds. Set `agents.defaults.heartbeat.timeoutSeconds` or per-agent `agents.entries.*.heartbeat.timeoutSeconds` for longer heartbeat work.
-- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeats" section only when heartbeats are enabled for the default agent (and `includeSystemPromptSection` is not `false`), and the run is flagged internally.
-- When heartbeats are disabled with `0m`, normal runs also omit `HEARTBEAT.md` from bootstrap context so the model does not see heartbeat-only instructions.
+- The heartbeat prompt is sent **verbatim** as the user message. The system prompt includes a "Heartbeats" section when heartbeats are enabled for the default agent, and the run is flagged internally.
+- When heartbeats are disabled with `0m`, the monitor cron job stays but is disabled, and its scratch is retained for when you re-enable the cadence.
- Active hours (`heartbeat.activeHours`) are checked in the configured timezone. Outside the window, heartbeats are skipped until the next tick inside the window.
-- Heartbeats automatically defer while cron work is active or queued. Set `heartbeat.skipWhenBusy: true` to also defer an agent on its own session-keyed subagent or nested command lanes; sibling agents no longer pause just because another agent has subagent work in flight.
+- Heartbeats automatically defer while cron work is active or queued, or while that agent's session-keyed subagent or nested command lanes are busy. Sibling agents do not pause each other.
## What the heartbeat prompt is for
@@ -87,7 +84,7 @@ If you want a heartbeat to do something very specific (e.g. "check Gmail PubSub
- If nothing needs attention, reply with **`HEARTBEAT_OK`**.
- Heartbeat runs may instead call `heartbeat_respond` with `notify: false` for no visible update, or `notify: true` plus `notificationText` for an alert. When present, the structured tool response takes precedence over the text fallback.
- A meaningful `heartbeat_respond` result with `notify: false` remains silent but is remembered as bounded internal context for the next user turn in that session. `no_change` acknowledgments and visible notifications are not stored this way.
-- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. The token is stripped and the reply is dropped if the remaining content is **≤ `ackMaxChars`** (default: 300).
+- During heartbeat runs, OpenClaw treats `HEARTBEAT_OK` as an ack when it appears at the **start or end** of the reply. The token is stripped and the reply is dropped if the remaining content is at most 300 characters.
- If `HEARTBEAT_OK` appears in the **middle** of a reply, it is not treated specially.
- For alerts, **do not** include `HEARTBEAT_OK`; return only the alert text.
@@ -102,16 +99,12 @@ Outside heartbeats, stray `HEARTBEAT_OK` at the start/end of a message is stripp
heartbeat: {
every: "30m", // default: 30m (0m disables)
model: "anthropic/claude-opus-4-6",
- includeReasoning: false, // default: false (deliver separate Thinking message when available)
- lightContext: false, // default: false; true keeps only HEARTBEAT.md from workspace bootstrap files
+ lightContext: false, // default: false; true skips workspace bootstrap files for heartbeat runs
isolatedSession: false, // default: false; true runs each heartbeat in a fresh session (no conversation history)
- skipWhenBusy: false, // default: false; true also waits for this agent's subagent/nested lanes
target: "last", // default: none | options: last | none | (core or plugin, e.g. "imessage")
to: "+15551234567", // optional channel-specific override
accountId: "ops-bot", // optional multi-account channel id
- prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
- includeSystemPromptSection: true, // default: true; false omits the ## Heartbeats system prompt section for the default agent
- ackMaxChars: 300, // max chars allowed after HEARTBEAT_OK
+ prompt: "Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
},
},
},
@@ -150,7 +143,7 @@ Example: two agents, only the second agent runs heartbeats.
target: "whatsapp",
to: "+15551234567",
timeoutSeconds: 45,
- prompt: "Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
+ prompt: "Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.",
},
},
],
@@ -230,18 +223,12 @@ Use `accountId` to target a specific account on multi-account channels like Tele
Optional model override for heartbeat runs (`provider/model`).
-
- When enabled, also deliver the separate `Thinking` message when available (same shape as `/reasoning on`).
-
- When true, heartbeat runs use lightweight bootstrap context and keep only `HEARTBEAT.md` from workspace bootstrap files.
+ When true, heartbeat runs use lightweight bootstrap context and skip workspace bootstrap files. Monitor scratch is injected by the heartbeat runner either way.
When true, each heartbeat runs in a fresh session with no prior conversation history. Uses the same isolation pattern as cron `sessionTarget: "isolated"`. Dramatically reduces per-heartbeat token cost. Combine with `lightContext: true` for maximum savings. Delivery routing still uses the main session context.
-
- When true, heartbeat runs defer on that agent's extra busy lanes: its own session-keyed subagent or nested command work. Cron lanes always defer heartbeats, even without this flag, so local-model hosts do not run cron and heartbeat prompts at the same time.
-
Optional session key for heartbeat runs.
@@ -271,18 +258,6 @@ Use `accountId` to target a specific account on multi-account channels like Tele
Overrides the default prompt body (not merged).
-
-
- Whether the default agent's `## Heartbeats` system prompt section is injected. Set `false` to keep heartbeat runtime behavior (cadence, delivery, HEARTBEAT.md) while omitting the heartbeat instructions from the agent system prompt.
-
-
-
- Max chars allowed after `HEARTBEAT_OK` before delivery.
-
-
-
- When true, suppresses tool error warning payloads during heartbeat runs.
-
Maximum seconds allowed for a heartbeat agent turn before it is aborted. Leave unset to use `agents.defaults.timeoutSeconds` when set, otherwise the heartbeat cadence capped at 600 seconds.
@@ -308,7 +283,6 @@ Use `accountId` to target a specific account on multi-account channels like Tele
- To deliver to a specific channel/recipient, set `target` + `to`. With `target: "last"`, delivery uses the last external channel for that session.
- Heartbeat deliveries allow direct/DM targets by default. Set `directPolicy: "block"` to suppress direct-target sends while still running the heartbeat turn.
- If the main queue, target session lane, cron lane, or an active cron job is busy, the heartbeat is skipped and retried later.
- - If `skipWhenBusy: true`, this agent's session-keyed subagent and nested lanes also defer heartbeat runs. Other agents' busy lanes do not defer this agent.
- If `target` resolves to no external destination, the run still happens but no outbound message is sent.
@@ -387,19 +361,32 @@ channels:
| Indicator-only (no messages) | `channels.defaults.heartbeat: { showOk: false, showAlerts: false, useIndicator: true }` |
| OKs in one channel only | `channels.telegram.heartbeat: { showOk: true }` |
-## HEARTBEAT.md (optional)
+## Monitor scratch (optional)
-If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the agent to read it. Think of it as your "heartbeat checklist": small, stable, and safe to consider every 30 minutes.
+Each heartbeat monitor cron job owns a private scratch document stored in the shared state database. Think of it as your "heartbeat checklist": small, stable, and safe to consider every 30 minutes. When scratch exists, its content is appended to the heartbeat prompt.
-On normal runs, `HEARTBEAT.md` is only injected when heartbeat guidance is enabled for the default agent. Disabling the heartbeat cadence with `0m` or setting `includeSystemPromptSection: false` omits it from normal bootstrap context.
+Manage it with the cron CLI (the job id comes from `openclaw cron list --all`):
-On the native Codex harness, `HEARTBEAT.md` content is not injected into the turn like other bootstrap files. If the file exists and has non-whitespace content, a heartbeat collaboration-mode note points Codex at the file and tells it to read the file before proceeding.
+```bash
+openclaw cron scratch # print the current scratch
+openclaw cron scratch --set "..." # replace it with exact text
+openclaw cron scratch --file notes.md # replace it from a file (- for stdin)
+openclaw cron scratch --unset # remove it
+```
-If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If the file is missing, the heartbeat still runs and the model decides what to do.
+Writes are compare-and-swap guarded: pass `--expected-revision ` to fail instead of overwriting a concurrent edit. Scratch is capped at 256 KiB and never appears in `cron list`/`cron runs` output.
+
+The agent can also update its own scratch: during a heartbeat turn, `heartbeat_respond` accepts an optional `scratch` string that fully replaces the monitor's scratch for future heartbeats.
+
+
+**Migrating from HEARTBEAT.md?** Run `openclaw doctor --fix`. Doctor imports each agent's workspace `HEARTBEAT.md` into the monitor's scratch, archives the original under the state directory (`backups/heartbeat-migration/`), and then removes the file. For one stable upgrade window, an unmigrated legacy file remains a read-only fallback when no scratch revision exists, with a Gateway warning directing you to Doctor; new workspaces and completed migrations use database scratch only.
+
+
+If scratch exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls. That skip is reported as `reason=empty-heartbeat-file`. If no scratch exists, the heartbeat still runs and the model decides what to do.
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
-Example `HEARTBEAT.md`:
+Example scratch:
```md
# Heartbeat checklist
@@ -411,7 +398,7 @@ Example `HEARTBEAT.md`:
### `tasks:` blocks
-`HEARTBEAT.md` also supports a small structured `tasks:` block for interval-based checks inside heartbeat itself.
+Scratch also supports a small structured `tasks:` block for interval-based checks inside heartbeat itself.
Example:
@@ -436,28 +423,21 @@ tasks:
- OpenClaw parses the `tasks:` block and checks each task against its own `interval`.
- Only **due** tasks are included in the heartbeat prompt for that tick.
- If no tasks are due, the heartbeat is skipped entirely (`reason=no-tasks-due`) to avoid a wasted model call.
- - Non-task content in `HEARTBEAT.md` is preserved and appended as additional context after the due-task list.
+ - Non-task scratch content is preserved and appended as additional context after the due-task list.
- Task last-run timestamps are stored in session state (`heartbeatTaskState`), so intervals survive normal restarts.
- Task timestamps are only advanced after a heartbeat run completes its normal reply path. Skipped `empty-heartbeat-file` / `no-tasks-due` runs do not mark tasks as completed.
-Task mode is useful when you want one heartbeat file to hold several periodic checks without paying for all of them every tick.
+Task mode is useful when you want one scratch document to hold several periodic checks without paying for all of them every tick.
-### Can the agent update HEARTBEAT.md?
+### Can the agent update its scratch?
-Yes - if you ask it to.
-
-`HEARTBEAT.md` is just a normal file in the agent workspace, so you can tell the agent (in a normal chat) something like:
-
-- "Update `HEARTBEAT.md` to add a daily calendar check."
-- "Rewrite `HEARTBEAT.md` so it's shorter and focused on inbox follow-ups."
-
-If you want this to happen proactively, you can also include an explicit line in your heartbeat prompt like: "If the checklist becomes stale, update HEARTBEAT.md with a better one."
+Yes. During a heartbeat turn, the agent can pass a `scratch` value to `heartbeat_respond` to fully replace the monitor scratch for future heartbeats. You can also ask it in a normal chat to run `openclaw cron scratch --set ...`, or edit the scratch yourself with the same command.
-Don't put secrets (API keys, phone numbers, private tokens) into `HEARTBEAT.md` - it becomes part of the prompt context.
+Don't put secrets (API keys, phone numbers, private tokens) into monitor scratch - it becomes part of the prompt context.
## Manual wake (on-demand)
@@ -485,24 +465,14 @@ openclaw system heartbeat enable # enable heartbeats
openclaw system heartbeat disable # disable heartbeats
```
-## Reasoning delivery (optional)
-
-By default, heartbeats deliver only the final "answer" payload.
-
-If you want transparency, enable:
-
-- `agents.defaults.heartbeat.includeReasoning: true`
-
-When enabled, heartbeats will also deliver a separate message prefixed `Thinking` (same shape as `/reasoning on`). This can be useful when the agent is managing multiple sessions/codexes and you want to see why it decided to ping you - but it can also leak more internal detail than you want. Prefer keeping it off in group chats.
-
## Cost awareness
Heartbeats run full agent turns. Shorter intervals burn more tokens. To reduce cost:
- Use `isolatedSession: true` to avoid sending full conversation history (~100K tokens down to ~2-5K per run).
-- Use `lightContext: true` to limit bootstrap files to just `HEARTBEAT.md`.
+- Use `lightContext: true` to skip workspace bootstrap files for heartbeat runs.
- Set a cheaper `model` (e.g. `ollama/llama3.2:1b`).
-- Keep `HEARTBEAT.md` small.
+- Keep the monitor scratch small.
- Use `target: "none"` if you only want internal state updates.
## Context overflow after heartbeat
diff --git a/docs/start/openclaw.md b/docs/start/openclaw.md
index a7c857e041bc..35b129ce2f30 100644
--- a/docs/start/openclaw.md
+++ b/docs/start/openclaw.md
@@ -64,7 +64,7 @@ When onboarding finishes, OpenClaw auto-opens the dashboard and prints a clean (
OpenClaw reads operating instructions and "memory" from its workspace directory.
-By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and creates it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`) automatically on onboarding or first agent run. `BOOTSTRAP.md` is only created for a brand-new workspace and should not come back after you delete it. `MEMORY.md` is optional and never auto-created; when present, it loads for normal sessions. Subagent sessions only inject `AGENTS.md` and `TOOLS.md`.
+By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and creates it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on onboarding or first agent run. `BOOTSTRAP.md` is only created for a brand-new workspace and should not come back after you delete it. `MEMORY.md` is optional and never auto-created; when present, it loads for normal sessions. Subagent sessions only inject `AGENTS.md` and `TOOLS.md`.
Treat this folder like OpenClaw's memory and make it a git repo (ideally private) so your `AGENTS.md` and memory files are backed up. If git is installed, brand-new workspaces are auto-initialized with `git init`.
@@ -168,11 +168,11 @@ Example:
## Heartbeats (proactive mode)
By default, OpenClaw runs a heartbeat every 30 minutes with the prompt:
-`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
-Set `agents.defaults.heartbeat.every: "0m"` to disable.
+`Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
+Set `agents.defaults.heartbeat.every: "0m"` to disable. Heartbeat checklists live in the monitor's cron scratch (see [Heartbeat](/gateway/heartbeat)); `openclaw doctor --fix` migrates a legacy workspace `HEARTBEAT.md` into it.
-- If `HEARTBEAT.md` exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls.
-- If the file is missing, the heartbeat still runs and the model decides what to do.
+- If the monitor scratch exists but is effectively empty (only blank lines, Markdown/HTML comments, Markdown headings like `# Heading`, fence markers, or empty checklist stubs), OpenClaw skips the heartbeat run to save API calls.
+- If no scratch exists, the heartbeat still runs and the model decides what to do.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), OpenClaw suppresses outbound delivery for that heartbeat.
- By default, heartbeat delivery to DM-style `user:` targets is allowed. Set `agents.defaults.heartbeat.directPolicy: "block"` to suppress direct-target delivery while keeping heartbeat runs active.
- Heartbeats run full agent turns - shorter intervals burn more tokens.
diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md
index 19a387a41592..f32e02c81bb3 100644
--- a/docs/tools/thinking.md
+++ b/docs/tools/thinking.md
@@ -122,7 +122,7 @@ Malformed local-model reasoning tags are handled conservatively. Closed `
## Heartbeats
-- Heartbeat probe body is the configured heartbeat prompt (default: `Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
+- Heartbeat probe body is the configured heartbeat prompt (default: `Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`). Inline directives in a heartbeat message apply as usual (but avoid changing session defaults from heartbeats).
- Heartbeat delivery defaults to the final payload only. To also send the separate `Thinking` message (when available), set `agents.defaults.heartbeat.includeReasoning: true` or per-agent `agents.entries.*.heartbeat.includeReasoning: true`.
## Web chat UI
diff --git a/extensions/codex/src/app-server/attempt-context.test.ts b/extensions/codex/src/app-server/attempt-context.test.ts
index de9610da946c..ee2cf8bd5308 100644
--- a/extensions/codex/src/app-server/attempt-context.test.ts
+++ b/extensions/codex/src/app-server/attempt-context.test.ts
@@ -93,7 +93,6 @@ describe("Codex app-server attempt context", () => {
contextFiles: [],
promptContextFiles: [],
developerInstructionFiles: [],
- heartbeatReferenceFiles: [],
},
skillsPrompt: "",
tools,
diff --git a/extensions/codex/src/app-server/attempt-context.ts b/extensions/codex/src/app-server/attempt-context.ts
index 0b46879e616d..9e738735c2c1 100644
--- a/extensions/codex/src/app-server/attempt-context.ts
+++ b/extensions/codex/src/app-server/attempt-context.ts
@@ -43,7 +43,6 @@ const CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES = new Set([
...CODEX_INHERITED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
...CODEX_TURN_SCOPED_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES,
]);
-const CODEX_HEARTBEAT_CONTEXT_BASENAME = "heartbeat.md";
const CODEX_MEMORY_CONTEXT_BASENAME = "memory.md";
const CODEX_MEMORY_TOOL_NAMES = new Set(["memory_search", "memory_get"]);
const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map([
@@ -53,7 +52,6 @@ const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map([
["tools.md", 40],
["bootstrap.md", 50],
["memory.md", 60],
- ["heartbeat.md", 70],
]);
type CodexBootstrapFile = Awaited>[number];
@@ -68,7 +66,6 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
promptContextFiles?: EmbeddedContextFile[];
developerInstructionFiles?: EmbeddedContextFile[];
turnScopedDeveloperInstructionFiles?: EmbeddedContextFile[];
- heartbeatReferenceFiles?: EmbeddedContextFile[];
memoryReferenceFiles?: EmbeddedContextFile[];
memoryToolRoutedBootstrapFiles?: CodexBootstrapFile[];
memoryToolNames?: string[];
@@ -77,7 +74,6 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
developerInstructions?: string;
turnScopedDeveloperInstructions?: string;
memoryCollaborationInstructions?: string;
- heartbeatCollaborationInstructions?: string;
};
/** Reads mirrored Codex session history for harness hooks. */
@@ -242,14 +238,12 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
)
? selectCodexWorkspaceTurnScopedDeveloperInstructionFiles(contextFiles)
: [];
- const heartbeatReferenceFiles = selectCodexWorkspaceHeartbeatReferenceFiles(contextFiles);
return {
bootstrapFiles,
contextFiles,
promptContextFiles,
developerInstructionFiles,
turnScopedDeveloperInstructionFiles,
- heartbeatReferenceFiles,
memoryReferenceFiles,
memoryToolRoutedBootstrapFiles,
memoryToolNames: [...params.memoryToolNames],
@@ -271,8 +265,6 @@ export async function buildCodexWorkspaceBootstrapContext(params: {
sandboxed: params.sandboxed,
})
: undefined,
- heartbeatCollaborationInstructions:
- renderCodexWorkspaceHeartbeatReference(heartbeatReferenceFiles),
};
} catch (error) {
embeddedAgentLog.warn("failed to load codex workspace bootstrap instructions", { error });
@@ -452,14 +444,9 @@ function buildCodexBootstrapInjectionStats(params: {
readCodexIndexedContextFileContent(developerInstructionIndex, pathValue, fileName));
let injectedChars = memoryToolRoutedFile ? 0 : (injected?.length ?? 0);
let truncated = memoryToolRoutedFile ? false : !file.missing && injectedChars < rawChars;
- if (injected === undefined) {
- if (CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName)) {
- injectedChars = rawChars;
- truncated = false;
- } else if (baseName === CODEX_HEARTBEAT_CONTEXT_BASENAME) {
- injectedChars = 0;
- truncated = false;
- }
+ if (injected === undefined && CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName)) {
+ injectedChars = rawChars;
+ truncated = false;
}
return {
name: displayName,
@@ -669,7 +656,7 @@ function renderCodexWorkspaceBootstrapPromptContext(
return undefined;
}
const lines = [
- "OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. TOOLS.md is provided as inherited Codex developer instructions. SOUL.md, IDENTITY.md, and USER.md are provided as turn-scoped collaboration instructions so native Codex subagents do not inherit them. HEARTBEAT.md is handled by heartbeat collaboration-mode guidance. Those files are not repeated here.",
+ "OpenClaw loaded these user-editable workspace files for the current turn. Codex loads AGENTS.md natively. TOOLS.md is provided as inherited Codex developer instructions. SOUL.md, IDENTITY.md, and USER.md are provided as turn-scoped collaboration instructions so native Codex subagents do not inherit them. Those files are not repeated here.",
"",
"# Project Context",
"",
@@ -694,7 +681,6 @@ function selectCodexWorkspacePromptContextFiles(
baseName &&
!CODEX_NATIVE_PROJECT_DOC_BASENAMES.has(baseName) &&
!CODEX_WORKSPACE_DEVELOPER_CONTEXT_BASENAMES.has(baseName) &&
- baseName !== CODEX_HEARTBEAT_CONTEXT_BASENAME &&
(!excludeMemory ||
!isCodexWorkspaceRootMemoryContextFile({
file,
@@ -787,37 +773,6 @@ function renderCodexWorkspaceDeveloperInstructions(params: {
return lines.join("\n").trim();
}
-function selectCodexWorkspaceHeartbeatReferenceFiles(
- contextFiles: EmbeddedContextFile[],
-): EmbeddedContextFile[] {
- return contextFiles
- .filter((file) => {
- const baseName = getCodexContextFileBasename(file.path);
- return (
- baseName === CODEX_HEARTBEAT_CONTEXT_BASENAME &&
- !isMissingCodexBootstrapContextFile(file) &&
- file.content.trim().length > 0
- );
- })
- .toSorted(compareCodexContextFiles);
-}
-
-function renderCodexWorkspaceHeartbeatReference(files: EmbeddedContextFile[]): string | undefined {
- if (files.length === 0) {
- return undefined;
- }
- const lines = [
- "## OpenClaw Heartbeat Workspace",
- "",
- "HEARTBEAT.md exists in the active agent workspace. Read it before proceeding with this heartbeat, then decide what action is appropriate.",
- "",
- ];
- for (const file of files) {
- lines.push(`- ${file.path}`);
- }
- return lines.join("\n").trim();
-}
-
function selectCodexWorkspaceMemoryReferenceFiles(params: {
bootstrapFiles: CodexBootstrapFile[];
workspaceDir: string;
diff --git a/extensions/codex/src/app-server/run-attempt-prompt.ts b/extensions/codex/src/app-server/run-attempt-prompt.ts
index aea1179432b1..50d7201d79df 100644
--- a/extensions/codex/src/app-server/run-attempt-prompt.ts
+++ b/extensions/codex/src/app-server/run-attempt-prompt.ts
@@ -290,8 +290,6 @@ export async function prepareCodexAttemptPrompt(context: CodexAttemptContext) {
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
skillsCollaborationInstructions,
memoryCollaborationInstructions: workspaceBootstrapContext.memoryCollaborationInstructions,
- heartbeatCollaborationInstructions:
- workspaceBootstrapContext.heartbeatCollaborationInstructions,
}).settings.developer_instructions ?? undefined,
);
const rebuildCodexPromptBuildFromCurrentProjection = async () => {
diff --git a/extensions/codex/src/app-server/run-attempt-turn-request.ts b/extensions/codex/src/app-server/run-attempt-turn-request.ts
index ef4e889fd45e..d76d1118114d 100644
--- a/extensions/codex/src/app-server/run-attempt-turn-request.ts
+++ b/extensions/codex/src/app-server/run-attempt-turn-request.ts
@@ -112,8 +112,6 @@ export async function prepareCodexAttemptTurnRequest(
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
skillsCollaborationInstructions: context.skillsCollaborationInstructions,
memoryCollaborationInstructions: workspaceBootstrapContext.memoryCollaborationInstructions,
- heartbeatCollaborationInstructions:
- workspaceBootstrapContext.heartbeatCollaborationInstructions,
preserveNativeTurnSettings: usesSupervisionConnection,
});
codexModelCallDiagnostics.setRequestPayloadBytes(utf8JsonByteLength(turnStartParams));
diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts
index 52ee0bbade96..b4f9fb44dfe9 100644
--- a/extensions/codex/src/app-server/run-attempt.test.ts
+++ b/extensions/codex/src/app-server/run-attempt.test.ts
@@ -395,8 +395,6 @@ async function buildCodexTurnContextForTest(
promptText: codexTurnPromptText,
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
memoryCollaborationInstructions: workspaceBootstrapContext.memoryCollaborationInstructions,
- heartbeatCollaborationInstructions:
- workspaceBootstrapContext.heartbeatCollaborationInstructions,
});
const collaborationInstructions =
turnStartParams.collaborationMode?.settings?.developer_instructions ?? "";
diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts
index d0cd241ebfac..3249aaa59e74 100644
--- a/extensions/codex/src/app-server/thread-lifecycle.test.ts
+++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts
@@ -1294,10 +1294,7 @@ describe("Codex app-server turn params", () => {
params.thinkLevel = "medium";
params.trigger = "heartbeat";
- const heartbeatCollaborationMode = buildTurnCollaborationMode(params, {
- heartbeatCollaborationInstructions:
- "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.",
- });
+ const heartbeatCollaborationMode = buildTurnCollaborationMode(params, {});
expect(heartbeatCollaborationMode.mode).toBe("default");
expect(heartbeatCollaborationMode.settings.model).toBe("gpt-5.4-codex");
expect(heartbeatCollaborationMode.settings.reasoning_effort).toBe("medium");
@@ -1310,15 +1307,10 @@ describe("Codex app-server turn params", () => {
expect(heartbeatCollaborationMode.settings.developer_instructions).toContain(
"If `heartbeat_respond` is not already available and `tool_search` is available",
);
- expect(heartbeatCollaborationMode.settings.developer_instructions).toContain(
- "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md.",
- );
params.bootstrapContextRunKind = "commitment-only";
const commitmentCollaborationMode = buildTurnCollaborationMode(params, {
turnScopedDeveloperInstructions: "Turn-only workspace instructions.",
- heartbeatCollaborationInstructions:
- "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.",
});
expect(commitmentCollaborationMode.settings.developer_instructions).toContain(
"# Collaboration Mode: Default",
@@ -1329,16 +1321,11 @@ describe("Codex app-server turn params", () => {
expect(commitmentCollaborationMode.settings.developer_instructions).not.toContain(
"This is an OpenClaw heartbeat turn",
);
- expect(commitmentCollaborationMode.settings.developer_instructions).not.toContain(
- "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md.",
- );
params.trigger = "user";
expect(
buildTurnCollaborationMode(params, {
turnScopedDeveloperInstructions: "Turn-only workspace instructions.",
- heartbeatCollaborationInstructions:
- "HEARTBEAT.md exists at /tmp/workspace/HEARTBEAT.md. Read it before proceeding.",
}).settings.developer_instructions,
).toContain("Turn-only workspace instructions.");
expect(
diff --git a/extensions/codex/src/app-server/turn-params.ts b/extensions/codex/src/app-server/turn-params.ts
index 747dacd596bc..b0504350e710 100644
--- a/extensions/codex/src/app-server/turn-params.ts
+++ b/extensions/codex/src/app-server/turn-params.ts
@@ -28,7 +28,6 @@ export function buildTurnStartParams(
turnScopedDeveloperInstructions?: string;
skillsCollaborationInstructions?: string;
memoryCollaborationInstructions?: string;
- heartbeatCollaborationInstructions?: string;
preserveNativeTurnSettings?: boolean;
},
): CodexTurnStartParams {
@@ -79,7 +78,6 @@ export function buildTurnStartParams(
turnScopedDeveloperInstructions: options.turnScopedDeveloperInstructions,
skillsCollaborationInstructions: options.skillsCollaborationInstructions,
memoryCollaborationInstructions: options.memoryCollaborationInstructions,
- heartbeatCollaborationInstructions: options.heartbeatCollaborationInstructions,
}),
}
: {}),
@@ -95,7 +93,6 @@ export function buildTurnCollaborationMode(
turnScopedDeveloperInstructions?: string;
skillsCollaborationInstructions?: string;
memoryCollaborationInstructions?: string;
- heartbeatCollaborationInstructions?: string;
} = {},
): CodexTurnCollaborationMode {
const model = options.model ?? params.modelId;
@@ -119,7 +116,6 @@ function buildTurnScopedCollaborationInstructions(
turnScopedDeveloperInstructions?: string;
skillsCollaborationInstructions?: string;
memoryCollaborationInstructions?: string;
- heartbeatCollaborationInstructions?: string;
} = {},
): string | null {
const contextInstructions = joinPresentSections(
@@ -131,11 +127,7 @@ function buildTurnScopedCollaborationInstructions(
return joinPresentSections(buildCronCollaborationInstructions(), contextInstructions);
}
if (params.trigger === "heartbeat" && params.bootstrapContextRunKind !== "commitment-only") {
- return joinPresentSections(
- buildHeartbeatCollaborationInstructions(),
- contextInstructions,
- options.heartbeatCollaborationInstructions,
- );
+ return joinPresentSections(buildHeartbeatCollaborationInstructions(), contextInstructions);
}
if (contextInstructions?.trim()) {
return joinPresentSections(buildDefaultCollaborationInstructions(), contextInstructions);
diff --git a/extensions/codex/test-api.ts b/extensions/codex/test-api.ts
index 81cc30f27774..0738ae24ed27 100644
--- a/extensions/codex/test-api.ts
+++ b/extensions/codex/test-api.ts
@@ -50,7 +50,6 @@ export function buildCodexHarnessPromptSnapshot(params: {
promptText?: string;
developerInstructionAdditions?: string;
turnScopedDeveloperInstructions?: string;
- heartbeatCollaborationInstructions?: string;
}): CodexHarnessPromptSnapshot {
const developerInstructions = joinPresentSections(
buildDeveloperInstructions(params.attempt, {
@@ -79,7 +78,6 @@ export function buildCodexHarnessPromptSnapshot(params: {
appServer: params.appServer,
promptText: params.promptText,
turnScopedDeveloperInstructions: params.turnScopedDeveloperInstructions,
- heartbeatCollaborationInstructions: params.heartbeatCollaborationInstructions,
}),
};
}
diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts
index 0d3daab539cd..77a36bc25de3 100644
--- a/packages/gateway-protocol/src/index.ts
+++ b/packages/gateway-protocol/src/index.ts
@@ -196,6 +196,10 @@ import {
CronRemoveParamsSchema,
CronRunParamsSchema,
CronRunsParamsSchema,
+ CronScratchGetParamsSchema,
+ CronScratchGetResultSchema,
+ CronScratchSetParamsSchema,
+ CronScratchSetResultSchema,
CronStatusParamsSchema,
CronUpdateParamsSchema,
DevicePairApproveParamsSchema,
@@ -950,6 +954,8 @@ export const validateCronUpdateParams = lazyCompile(CronUpdateParamsSchema);
export const validateCronRemoveParams = lazyCompile(CronRemoveParamsSchema);
export const validateCronRunParams = lazyCompile(CronRunParamsSchema);
export const validateCronRunsParams = lazyCompile(CronRunsParamsSchema);
+export const validateCronScratchGetParams = lazyCompile(CronScratchGetParamsSchema);
+export const validateCronScratchSetParams = lazyCompile(CronScratchSetParamsSchema);
export const validateDevicePairListParams = lazyCompile(DevicePairListParamsSchema);
export const validateDevicePairApproveParams = lazyCompile(DevicePairApproveParamsSchema);
export const validateDevicePairRejectParams = lazyCompile(DevicePairRejectParamsSchema);
@@ -1458,6 +1464,10 @@ export {
CronRemoveParamsSchema,
CronRunParamsSchema,
CronRunsParamsSchema,
+ CronScratchGetParamsSchema,
+ CronScratchGetResultSchema,
+ CronScratchSetParamsSchema,
+ CronScratchSetResultSchema,
LogsTailParamsSchema,
LogsTailResultSchema,
TerminalOpenParamsSchema,
@@ -1966,6 +1976,10 @@ export type {
CronRemoveParams,
CronRunParams,
CronRunsParams,
+ CronScratchGetParams,
+ CronScratchGetResult,
+ CronScratchSetParams,
+ CronScratchSetResult,
CronRunLogEntry,
ApprovalKind,
ApprovalDecision,
diff --git a/packages/gateway-protocol/src/schema/cron.ts b/packages/gateway-protocol/src/schema/cron.ts
index ff4e0c6d705f..97699e36a570 100644
--- a/packages/gateway-protocol/src/schema/cron.ts
+++ b/packages/gateway-protocol/src/schema/cron.ts
@@ -566,6 +566,40 @@ export const CronStatusParamsSchema = closedObject({});
/** Looks up a job by stable id or legacy jobId alias. */
export const CronGetParamsSchema = cronIdOrJobIdParams({});
+export const CronScratchSchema = closedObject({
+ content: Type.String({ maxLength: 262144 }),
+ revision: Type.Integer({ minimum: 1 }),
+ updatedAtMs: Type.Integer({ minimum: 0 }),
+});
+
+/** Reads private per-job scratch without adding it to the public job schema. */
+export const CronScratchGetParamsSchema = cronIdOrJobIdParams({});
+export const CronScratchGetResultSchema = closedObject({
+ scratch: Type.Union([CronScratchSchema, Type.Null()]),
+ // Monotonic across unset/recreate; pass as expectedRevision for safe writes.
+ currentRevision: Type.Integer({ minimum: 0 }),
+ maxBytes: Type.Integer({ minimum: 1 }),
+});
+
+/** Compare-and-swaps or clears private per-job scratch. */
+export const CronScratchSetParamsSchema = cronIdOrJobIdParams({
+ content: Type.Union([Type.String({ maxLength: 262144 }), Type.Null()]),
+ expectedRevision: Type.Optional(Type.Integer({ minimum: 0 })),
+});
+export const CronScratchSetResultSchema = Type.Union([
+ closedObject({
+ ok: Type.Literal(true),
+ scratch: Type.Union([CronScratchSchema, Type.Null()]),
+ currentRevision: Type.Integer({ minimum: 0 }),
+ maxBytes: Type.Integer({ minimum: 1 }),
+ }),
+ closedObject({
+ ok: Type.Literal(false),
+ reason: Type.Literal("revision-conflict"),
+ currentRevision: Type.Integer({ minimum: 0 }),
+ }),
+]);
+
/** Creates a scheduled job with schedule, target, payload, and delivery policy. */
export const CronAddParamsSchema = closedObject({
name: NonEmptyString,
diff --git a/packages/gateway-protocol/src/schema/cron.types.ts b/packages/gateway-protocol/src/schema/cron.types.ts
index 43d7f2615bf2..d0132b0b108e 100644
--- a/packages/gateway-protocol/src/schema/cron.types.ts
+++ b/packages/gateway-protocol/src/schema/cron.types.ts
@@ -11,6 +11,10 @@ import type {
CronRunLogEntrySchema,
CronRunParamsSchema,
CronRunsParamsSchema,
+ CronScratchGetParamsSchema,
+ CronScratchGetResultSchema,
+ CronScratchSetParamsSchema,
+ CronScratchSetResultSchema,
CronStatusParamsSchema,
CronUpdateParamsSchema,
} from "./cron.js";
@@ -26,4 +30,8 @@ export type CronUpdateParams = Static;
export type CronRemoveParams = Static;
export type CronRunParams = Static;
export type CronRunsParams = Static;
+export type CronScratchGetParams = Static;
+export type CronScratchGetResult = Static;
+export type CronScratchSetParams = Static;
+export type CronScratchSetResult = Static;
export type CronRunLogEntry = Static;
diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts
index 265c4ba44489..4160c794f3b8 100644
--- a/packages/gateway-protocol/src/schema/protocol-schemas.ts
+++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts
@@ -268,6 +268,10 @@ import {
CronRunLogEntrySchema,
CronRunParamsSchema,
CronRunsParamsSchema,
+ CronScratchGetParamsSchema,
+ CronScratchGetResultSchema,
+ CronScratchSetParamsSchema,
+ CronScratchSetResultSchema,
CronStatusParamsSchema,
CronUpdateParamsSchema,
} from "./cron.js";
@@ -1125,6 +1129,10 @@ export const ProtocolSchemas = {
CronRemoveParams: CronRemoveParamsSchema,
CronRunParams: CronRunParamsSchema,
CronRunsParams: CronRunsParamsSchema,
+ CronScratchGetParams: CronScratchGetParamsSchema,
+ CronScratchGetResult: CronScratchGetResultSchema,
+ CronScratchSetParams: CronScratchSetParamsSchema,
+ CronScratchSetResult: CronScratchSetResultSchema,
CronRunLogEntry: CronRunLogEntrySchema,
...LogMigrationProtocolSchemas,
...TerminalProtocolSchemas,
diff --git a/scripts/lib/workspace-bootstrap-smoke.mjs b/scripts/lib/workspace-bootstrap-smoke.mjs
index 24c8fbf74928..33c2ae17868f 100644
--- a/scripts/lib/workspace-bootstrap-smoke.mjs
+++ b/scripts/lib/workspace-bootstrap-smoke.mjs
@@ -17,13 +17,14 @@ export const WORKSPACE_TEMPLATE_PACK_PATHS = [
"docs/reference/templates/BOOTSTRAP.md",
];
+// HEARTBEAT.md ships in the template pack for docs/doctor context but is no
+// longer seeded into new workspaces; heartbeat context lives in cron scratch.
const REQUIRED_BOOTSTRAP_WORKSPACE_FILES = [
"AGENTS.md",
"SOUL.md",
"TOOLS.md",
"IDENTITY.md",
"USER.md",
- "HEARTBEAT.md",
"BOOTSTRAP.md",
];
diff --git a/src/agents/bootstrap-files.test.ts b/src/agents/bootstrap-files.test.ts
index f5c92e29ceb1..7a75c1c84c76 100644
--- a/src/agents/bootstrap-files.test.ts
+++ b/src/agents/bootstrap-files.test.ts
@@ -190,7 +190,6 @@ describe("resolveBootstrapFilesForRun", () => {
"TOOLS.md",
"IDENTITY.md",
"USER.md",
- "HEARTBEAT.md",
"BOOTSTRAP.md",
]);
expect(warnings).toHaveLength(3);
@@ -398,9 +397,8 @@ describe("resolveBootstrapContextForRun", () => {
expect(contextFileNames.has("AGENTS.md")).toBe(true);
});
- it("uses heartbeat-only bootstrap files in lightweight heartbeat mode", async () => {
+ it("keeps bootstrap context empty in lightweight heartbeat mode", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
- await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8");
await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "persona", "utf8");
const files = await resolveBootstrapFilesForRun({
@@ -409,8 +407,8 @@ describe("resolveBootstrapContextForRun", () => {
runKind: "heartbeat",
});
- expect(files.map((file) => file.name)).toStrictEqual(["HEARTBEAT.md"]);
- expect(files[0]?.content).toBe("check inbox");
+ // Heartbeat context comes from cron scratch via the heartbeat runner now.
+ expect(files).toStrictEqual([]);
});
it("keeps bootstrap context empty in lightweight cron mode", async () => {
@@ -440,48 +438,9 @@ describe("resolveBootstrapContextForRun", () => {
expect(files.map((file) => file.name)).toContain("SOUL.md");
});
- it("keeps HEARTBEAT.md for non-heartbeat runs when heartbeat cadence is enabled", async () => {
+ it("never re-imports a leftover workspace HEARTBEAT.md into bootstrap context", async () => {
const workspaceDir = await createHeartbeatAgentsWorkspace();
- const files = await resolveBootstrapFilesForRun({
- workspaceDir,
- config: {
- agents: {
- defaults: {
- heartbeat: {},
- },
- list: [{ id: "main" }],
- },
- },
- });
-
- expect(files.map((file) => file.name)).toContain("HEARTBEAT.md");
- });
-
- it("drops HEARTBEAT.md for non-heartbeat runs when the heartbeat cadence is disabled", async () => {
- const workspaceDir = await createHeartbeatAgentsWorkspace();
-
- const files = await resolveBootstrapFilesForRun({
- workspaceDir,
- config: {
- agents: {
- defaults: {
- heartbeat: {
- every: "0m",
- },
- },
- list: [{ id: "main" }],
- },
- },
- });
-
- expectHeartbeatExcludedAndAgentsKept(files);
- });
-
- it("keeps HEARTBEAT.md for actual heartbeat runs", async () => {
- const workspaceDir = await makeTempWorkspace("openclaw-bootstrap-");
- await fs.writeFile(path.join(workspaceDir, "HEARTBEAT.md"), "check inbox", "utf8");
-
const files = await resolveBootstrapFilesForRun({
workspaceDir,
runKind: "heartbeat",
@@ -493,8 +452,7 @@ describe("resolveBootstrapContextForRun", () => {
},
});
- const fileNames = files.map((file) => file.name);
- expect(fileNames).toContain("HEARTBEAT.md");
+ expectHeartbeatExcludedAndAgentsKept(files);
});
});
diff --git a/src/agents/bootstrap-files.ts b/src/agents/bootstrap-files.ts
index e4cc44d23e92..b04e82d639a1 100644
--- a/src/agents/bootstrap-files.ts
+++ b/src/agents/bootstrap-files.ts
@@ -10,7 +10,7 @@ import type { AgentContextInjection } from "../config/types.agent-defaults.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readFileWindowFully } from "../infra/file-read.js";
import { resolveUserPath } from "../utils.js";
-import { resolveAgentConfig, resolveSessionAgentIds } from "./agent-scope.js";
+import { resolveAgentConfig } from "./agent-scope.js";
import { getOrLoadBootstrapFiles } from "./bootstrap-cache.js";
import { applyBootstrapHookOverrides } from "./bootstrap-hooks.js";
import type { BootstrapContextRunKind } from "./bootstrap-mode.js";
@@ -20,9 +20,7 @@ import {
resolveBootstrapMaxChars,
resolveBootstrapTotalMaxChars,
} from "./embedded-agent-helpers.js";
-import { shouldIncludeHeartbeatGuidanceForSystemPrompt } from "./heartbeat-system-prompt.js";
import {
- DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
filterBootstrapFilesForSession,
isWorkspaceSetupCompleted,
@@ -203,55 +201,14 @@ function applyContextModeFilter(params: {
runKind?: BootstrapContextRunKind;
}): WorkspaceBootstrapFile[] {
const contextMode = params.contextMode ?? "full";
- const runKind = params.runKind ?? "default";
if (contextMode !== "lightweight") {
return params.files;
}
- if (runKind === "heartbeat") {
- return params.files.filter((file) => file.name === "HEARTBEAT.md");
- }
- // cron/default lightweight mode keeps bootstrap context empty on purpose.
+ // Heartbeat scratch is injected by the heartbeat runner, not bootstrap files.
+ // Cron/default lightweight mode also keeps bootstrap context empty on purpose.
return [];
}
-function shouldExcludeHeartbeatBootstrapFile(params: {
- config?: OpenClawConfig;
- sessionKey?: string;
- sessionId?: string;
- agentId?: string;
- runKind?: BootstrapContextRunKind;
-}): boolean {
- if (params.runKind === "commitment-only") {
- return true;
- }
- if (!params.config || params.runKind === "heartbeat") {
- return false;
- }
- const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
- sessionKey: params.sessionKey ?? params.sessionId,
- config: params.config,
- agentId: params.agentId,
- });
- if (sessionAgentId !== defaultAgentId) {
- return false;
- }
- return !shouldIncludeHeartbeatGuidanceForSystemPrompt({
- config: params.config,
- agentId: sessionAgentId,
- defaultAgentId,
- });
-}
-
-function filterHeartbeatBootstrapFile(
- files: WorkspaceBootstrapFile[],
- excludeHeartbeatBootstrapFile: boolean,
-): WorkspaceBootstrapFile[] {
- if (!excludeHeartbeatBootstrapFile) {
- return files;
- }
- return files.filter((file) => file.name !== DEFAULT_HEARTBEAT_FILENAME);
-}
-
function filterCompletedWorkspaceBootstrapFile(
files: WorkspaceBootstrapFile[],
setupCompleted: boolean,
@@ -298,7 +255,6 @@ export async function resolveBootstrapFilesForRun(params: {
contextMode?: BootstrapContextMode;
runKind?: BootstrapContextRunKind;
}): Promise {
- const excludeHeartbeatBootstrapFile = shouldExcludeHeartbeatBootstrapFile(params);
const sessionKey = params.sessionKey ?? params.sessionId;
const workspaceSetupCompleted = await isWorkspaceSetupCompletedForContext(params.workspaceDir);
const rawFiles = params.sessionKey
@@ -330,11 +286,7 @@ export async function resolveBootstrapFilesForRun(params: {
workspaceSetupCompleted,
params.workspaceDir,
);
- return sanitizeBootstrapFiles(
- filterHeartbeatBootstrapFile(filteredUpdated, excludeHeartbeatBootstrapFile),
- params.workspaceDir,
- params.warn,
- );
+ return sanitizeBootstrapFiles(filteredUpdated, params.workspaceDir, params.warn);
}
/** Resolves both raw bootstrap metadata and bounded context files for a run. */
diff --git a/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts
index 69e6cd7826cd..d95dbf8e909a 100644
--- a/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts
+++ b/src/agents/embedded-agent-helpers.buildbootstrapcontextfiles.test.ts
@@ -128,13 +128,13 @@ describe("buildBootstrapContextFiles", () => {
const maxChars = EXPECTED_DEFAULT_BOOTSTRAP_MAX_CHARS;
const files = [
makeFile({
- name: "HEARTBEAT.md",
- path: "/tmp/HEARTBEAT.md",
+ name: "USER.md",
+ path: "/tmp/USER.md",
content: "a".repeat(maxChars * 2),
}),
];
const [result] = buildBootstrapContextFiles(files, { maxChars });
- expect(result?.content).toContain("[...truncated, read HEARTBEAT.md for full content...]");
+ expect(result?.content).toContain("[...truncated, read USER.md for full content...]");
expect(result?.content.length).toBeLessThanOrEqual(maxChars);
});
it("keeps policy digest lines from oversized AGENTS.md middle content", () => {
@@ -164,8 +164,8 @@ describe("buildBootstrapContextFiles", () => {
const content = `HEAD-${"a".repeat(1_000)}-TAIL`;
const files = [
makeFile({
- name: "HEARTBEAT.md",
- path: "/tmp/HEARTBEAT.md",
+ name: "USER.md",
+ path: "/tmp/USER.md",
content,
}),
];
@@ -180,8 +180,8 @@ describe("buildBootstrapContextFiles", () => {
const content = `HEAD-${"a".repeat(1_000)}-TAIL`;
const files = [
makeFile({
- name: "HEARTBEAT.md",
- path: "/tmp/HEARTBEAT.md",
+ name: "USER.md",
+ path: "/tmp/USER.md",
content,
}),
];
diff --git a/src/agents/gpt5-prompt-overlay.ts b/src/agents/gpt5-prompt-overlay.ts
index 3d425a57f7ff..7688bc10337c 100644
--- a/src/agents/gpt5-prompt-overlay.ts
+++ b/src/agents/gpt5-prompt-overlay.ts
@@ -30,7 +30,7 @@ Live chat: short, natural, human. No memo voice, long preamble, wall, repetition
/** @deprecated OpenAI/Codex provider-owned prompt overlay helper; do not use from third-party plugins. */
export const GPT5_HEARTBEAT_PROMPT_OVERLAY = `### Heartbeats
-Heartbeat = useful proactive progress, not chatter. Wake, orient, read HEARTBEAT.md, act.
+Heartbeat = useful proactive progress, not chatter. Wake, orient, use the provided monitor scratch, act.
Assigned/ongoing work: pursue spirit with judgment. Quiet check counts only if real blocker/urgent interruption.
No rote loops; orientation != accomplishment. Prefer action/silent progress.
Never repetitive "same/no change/still" updates.
diff --git a/src/agents/heartbeat-system-prompt.ts b/src/agents/heartbeat-system-prompt.ts
index 6bf789c8d80f..01b6b4cd91fc 100644
--- a/src/agents/heartbeat-system-prompt.ts
+++ b/src/agents/heartbeat-system-prompt.ts
@@ -59,7 +59,7 @@ function isHeartbeatCadenceEnabled(heartbeat?: HeartbeatConfig): boolean {
}
/** Returns true when heartbeat guidance should be included in the system prompt. */
-export function shouldIncludeHeartbeatGuidanceForSystemPrompt(params: {
+function shouldIncludeHeartbeatGuidanceForSystemPrompt(params: {
config?: OpenClawConfig;
agentId?: string;
defaultAgentId?: string;
diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts
index 1de336b06dd3..18d371a97b1d 100644
--- a/src/agents/system-prompt.ts
+++ b/src/agents/system-prompt.ts
@@ -81,9 +81,9 @@ const CONTEXT_FILE_ORDER = new Map([
["memory.md", 70],
]);
-const DYNAMIC_CONTEXT_FILE_BASENAMES = new Set(["heartbeat.md"]);
+const DYNAMIC_CONTEXT_FILE_BASENAMES = new Set();
const DEFAULT_HEARTBEAT_PROMPT_CONTEXT_BLOCK =
- "Default heartbeat prompt:\n`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`";
+ "Default heartbeat prompt:\n`Follow the heartbeat monitor scratch context when provided. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`";
const SYSTEM_PROMPT_STABLE_PREFIX_CACHE_LIMIT = 64;
type StablePromptPrefixCacheEntry = {
diff --git a/src/agents/tools/heartbeat-response-tool.test.ts b/src/agents/tools/heartbeat-response-tool.test.ts
index db04635480e2..8484c3d252b8 100644
--- a/src/agents/tools/heartbeat-response-tool.test.ts
+++ b/src/agents/tools/heartbeat-response-tool.test.ts
@@ -21,6 +21,7 @@ type HeartbeatResponseDetails = {
notificationText?: string;
priority?: string;
nextCheck?: string;
+ scratch?: string;
};
describe("createHeartbeatResponseTool", () => {
@@ -77,6 +78,26 @@ describe("createHeartbeatResponseTool", () => {
).rejects.toThrow("heartbeat_respond already recorded");
});
+ it("captures scratch without echoing future prompt content to the model", async () => {
+ const tool = createHeartbeatResponseTool();
+ const scratch = "Private monitor context that must not enter tool output.";
+
+ const result = await tool.execute("call-1", {
+ outcome: "progress",
+ notify: false,
+ summary: "Updated monitor context.",
+ scratch,
+ });
+
+ const details = result.details as HeartbeatResponseDetails;
+ expect(details.scratch).toBe(scratch);
+ expect(JSON.stringify(result.content)).not.toContain(scratch);
+ expect(JSON.stringify(details)).not.toContain(scratch);
+ expect(result.content).toEqual([
+ expect.objectContaining({ text: expect.stringContaining('"scratchPending": true') }),
+ ]);
+ });
+
it("accepts notification text and optional scheduling metadata", async () => {
const tool = createHeartbeatResponseTool();
diff --git a/src/agents/tools/heartbeat-response-tool.ts b/src/agents/tools/heartbeat-response-tool.ts
index 18c3b7c61452..5c418c553dce 100644
--- a/src/agents/tools/heartbeat-response-tool.ts
+++ b/src/agents/tools/heartbeat-response-tool.ts
@@ -12,10 +12,11 @@ import {
HEARTBEAT_TOOL_PRIORITIES,
normalizeHeartbeatToolResponse,
} from "../../auto-reply/heartbeat-tool-response.js";
+import { assertCronJobScratchContent } from "../../cron/scratch-contract.js";
import { readSnakeCaseParamRaw } from "../../param-key.js";
import { optionalStringEnum, stringEnum } from "../schema/string-enum.js";
import type { AnyAgentTool } from "./common.js";
-import { jsonResult, ToolInputError } from "./common.js";
+import { textResult, ToolInputError } from "./common.js";
const HeartbeatResponseToolSchema = Type.Object(
{
@@ -26,6 +27,7 @@ const HeartbeatResponseToolSchema = Type.Object(
reason: Type.Optional(Type.String()),
priority: optionalStringEnum(HEARTBEAT_TOOL_PRIORITIES),
nextCheck: Type.Optional(Type.String()),
+ scratch: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
@@ -53,6 +55,13 @@ export function createHeartbeatResponseTool(): AnyAgentTool {
throw new ToolInputError("Heartbeat response arguments required");
}
readRequiredBoolean(args, "notify");
+ if (typeof args.scratch === "string") {
+ try {
+ assertCronJobScratchContent(args.scratch);
+ } catch (error) {
+ throw new ToolInputError(error instanceof Error ? error.message : String(error));
+ }
+ }
const response = normalizeHeartbeatToolResponse(args);
if (!response) {
throw new ToolInputError(
@@ -65,10 +74,34 @@ export function createHeartbeatResponseTool(): AnyAgentTool {
throw new ToolInputError("heartbeat_respond already recorded for this turn");
}
recorded = true;
- return jsonResult({
- status: "recorded",
- ...response,
- });
+ const { scratch, ...publicResponse } = response;
+ const details = { status: "recorded" as const, ...publicResponse } as typeof response & {
+ status: "recorded";
+ };
+ if (scratch !== undefined) {
+ // Keep future prompt content out of model-visible tool output and logs;
+ // the runner receives it through the internal result details only.
+ Object.defineProperty(details, "scratch", { value: scratch, enumerable: false });
+ }
+ return textResult(
+ JSON.stringify(
+ {
+ status: "recorded",
+ ...publicResponse,
+ ...(scratch !== undefined
+ ? {
+ // Persistence is a runner-side CAS after the turn; do not claim
+ // success here. A lost race is logged and retryable next beat.
+ scratchPending: true,
+ scratchBytes: Buffer.byteLength(scratch, "utf8"),
+ }
+ : {}),
+ },
+ null,
+ 2,
+ ),
+ details,
+ );
},
};
}
diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts
index b977b8154df6..7f20386cbd05 100644
--- a/src/agents/workspace.test.ts
+++ b/src/agents/workspace.test.ts
@@ -826,19 +826,13 @@ describe("ensureAgentWorkspace", () => {
await expect(isWorkspaceBootstrapPending(tempDir)).resolves.toBe(false);
});
- it("writes the clean HEARTBEAT runtime template into new workspaces", async () => {
+ it("no longer seeds HEARTBEAT.md into new workspaces", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");
await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
- const heartbeat = await fs.readFile(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME), "utf-8");
- expect(heartbeat).not.toContain("```");
- expect(heartbeat).toContain(
- "# Keep this file empty (or with only comments) to skip heartbeat API calls.",
- );
- expect(heartbeat).toContain(
- "# Add tasks below when you want the agent to check something periodically.",
- );
+ // Heartbeat monitor context lives in cron scratch now; new workspaces get no file.
+ await expectPathMissing(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
});
it("does not recreate optional bootstrap files when workspace setup is already completed", async () => {
@@ -870,7 +864,6 @@ describe("ensureAgentWorkspace", () => {
await fs.unlink(path.join(tempDir, DEFAULT_SOUL_FILENAME));
await fs.unlink(path.join(tempDir, DEFAULT_IDENTITY_FILENAME));
await fs.unlink(path.join(tempDir, DEFAULT_USER_FILENAME));
- await fs.unlink(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME));
await writeWorkspaceFile({
dir: tempDir,
name: DEFAULT_AGENTS_FILENAME,
@@ -1148,7 +1141,6 @@ describe("filterBootstrapFilesForSession", () => {
{ name: "TOOLS.md", path: "/w/TOOLS.md", content: "", missing: false },
{ name: "IDENTITY.md", path: "/w/IDENTITY.md", content: "", missing: false },
{ name: "USER.md", path: "/w/USER.md", content: "", missing: false },
- { name: "HEARTBEAT.md", path: "/w/HEARTBEAT.md", content: "", missing: false },
{ name: "BOOTSTRAP.md", path: "/w/BOOTSTRAP.md", content: "", missing: false },
{ name: "MEMORY.md", path: "/w/MEMORY.md", content: "", missing: false },
];
diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts
index 619c1e854aa3..c5d049e00295 100644
--- a/src/agents/workspace.ts
+++ b/src/agents/workspace.ts
@@ -38,10 +38,7 @@ import {
type WorkspaceStateSnapshot,
type WorkspaceSetupState,
} from "./workspace-state-store.js";
-import {
- resolveWorkspaceTemplateDir,
- resolveWorkspaceTemplateSearchDirs,
-} from "./workspace-templates.js";
+import { resolveWorkspaceTemplateSearchDirs } from "./workspace-templates.js";
export {
DEFAULT_AGENT_WORKSPACE_DIR,
resolveDefaultAgentWorkspaceDir,
@@ -150,10 +147,7 @@ async function loadTemplate(name: string): Promise {
}
const pending = (async () => {
- const templateDirs =
- name === DEFAULT_HEARTBEAT_FILENAME
- ? [await resolveWorkspaceTemplateDir()]
- : await resolveWorkspaceTemplateSearchDirs();
+ const templateDirs = await resolveWorkspaceTemplateSearchDirs();
const triedPaths: string[] = [];
for (const templateDir of templateDirs) {
const templatePath = path.join(templateDir, name);
@@ -187,7 +181,6 @@ export type WorkspaceBootstrapFileName =
| typeof DEFAULT_TOOLS_FILENAME
| typeof DEFAULT_IDENTITY_FILENAME
| typeof DEFAULT_USER_FILENAME
- | typeof DEFAULT_HEARTBEAT_FILENAME
| typeof DEFAULT_BOOTSTRAP_FILENAME
| typeof DEFAULT_MEMORY_FILENAME;
@@ -217,7 +210,6 @@ const VALID_BOOTSTRAP_NAMES: ReadonlySet = new Set([
DEFAULT_TOOLS_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
- DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_MEMORY_FILENAME,
]);
@@ -226,7 +218,6 @@ const OPTIONAL_BOOTSTRAP_FILENAMES: ReadonlySet = new Set([
DEFAULT_SOUL_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
- DEFAULT_HEARTBEAT_FILENAME,
]);
export const WORKSPACE_VANISHED_ERROR_CODE = "WORKSPACE_VANISHED";
@@ -383,7 +374,7 @@ async function workspaceRequiredBootstrapLooksCustomized(
dir: string,
opts?: { generatedHashes?: ReadonlyMap },
): Promise {
- const fileNames = [DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME, DEFAULT_HEARTBEAT_FILENAME];
+ const fileNames = [DEFAULT_AGENTS_FILENAME, DEFAULT_TOOLS_FILENAME];
const generatedHashes = opts?.generatedHashes;
if (generatedHashes && generatedHashes.size > 0) {
for (const fileName of fileNames) {
@@ -499,7 +490,6 @@ async function collectGeneratedBootstrapHashes(dir: string): Promise