Skip to main content
LabsAgent Notes7 min read

The heartbeat that cost money nobody asked for

OpenClawDocker
The heartbeat that cost money nobody asked for
The only element Z-Image-Turbo 1.0 6-bit rendered with unshakable confidence was the big red arrow. Everything else looks appropriately worried about the invoice.

The expensive request was not a user request.

It was a default.

During the July deployment, OpenClaw's heartbeat behavior generated roughly 56 unwanted API calls per day. The recorded turns carried about 17,000–21,000 tokens each. A background schedule multiplied a full-context agent turn without anyone asking a question.

The first fix was not "use a cheaper model." It was to stop creating work that had no purpose.

What a heartbeat does

A heartbeat is a scheduled agent turn. It wakes the agent, loads context, sends a model request, and may produce a response even though no human message arrived.

That could be useful for monitoring or proactive work. It was expensive when the default interval was mistaken for an intentional schedule.

Heartbeat every 30 minutes
  24 hours × 2 turns/hour = 48 turns/day
  each turn loads context and consumes input/output tokens

Explicit cron
  one defined job at one defined time
  one reason for waking the agent

The project's butlers did not need recurring heartbeat turns. They needed to respond to Telegram messages and run explicit cron jobs for the creative profiles.

The configuration trap

The deployed configuration had:

heartbeat: {
  activeHours: { start: "08:00", end: "22:00" }
}

The presence of the heartbeat block enabled heartbeats. The every field was unset. OpenClaw defaulted to 30-minute intervals. The comments in the configuration did not warn that removing the block entirely could make things worse — absence of the every field was not absence of behavior. It was an instruction to use the default.

Each heartbeat ran a complete LLM agent turn with full context: system prompt, tools, HEARTBEAT.md, conversation history. The result was 17k–21k input tokens per turn, at 30-minute intervals, around the clock.

The lesson was broader than OpenClaw: absence of configuration is not always absence of behavior.

Sometimes it means "use the default."

The investigation

On July 7, the OpenRouter logs were reviewed.

The numbers were clear: approximately 56 API calls per day, each carrying 17k–21k input tokens, running 24 hours a day. The turns were not responses to user messages. They were heartbeat turns triggered by the default interval.

The root cause was traced to agents.json5, which defined heartbeat: { activeHours: { start: "08:00", end: "22:00" } } on both the husband and wife agents. The activeHours block was present, but every was unset. OpenClaw's default behavior when every was missing was to fire every 30 minutes.

The Docker container compounded the problem. It ran in UTC, while the activeHours window was set for the user's timezone. The heartbeats that were supposed to fire during waking hours (08:00–22:00) were instead firing from 16:00 to 06:00 UTC+8 — overnight, when no one was watching.

(I had configured the butlers to behave themselves during working hours. They behaved themselves at 3 AM instead. The configuration was correct in every particular except the one that mattered: which timezone the container thought it was in.)

The fix

The heartbeat blocks were removed entirely from both husband and wife agents, replaced with:

heartbeat: { every: "0m" }

The 0m setting was the only supported way to disable heartbeats. Removing the block entirely could cause OpenClaw to fall back to the 30-minute default — the same default that had caused the problem. That was a small configuration detail with a large cost multiplier.

--no-deliver was added to all four cron jobs (writer-diary, writer-reflection, maker-action, maker-reflection) to suppress "Delivering to Telegram requires target" errors. Writer and maker had no chat surface; their cron output was written to workspace files, not delivered as messages.

deploy/check-agents.sh was created as a convenience script for viewing the latest writer and maker output from a local machine via SSH.

The husband and wife session state and accumulated workspace memory were wiped to fresh zero. The first cron runs after the reset were verified: writer and maker both produced in-character content per their SOUL.md instructions.

Time made the problem harder to see

The same investigation also uncovered a container-time mismatch.

Cron schedules were registered for Asia/Shanghai, while the Docker container ran in UTC. The system prompt supplied the wrong "today" date to cron-driven agents. A writer could correctly follow its instruction to write memory/YYYY-MM-DD.md and still overwrite the previous day's file because the clock was eight hours behind the schedule.

cron: Asia/Shanghai 02:00 ───────────────┐
container clock: UTC date still yesterday ─┴─ agent writes yesterday's filename

The intended correction was to add TZ=Asia/Shanghai to the Docker Compose environment so the container clock matched the cron timezone. That fix would be applied later, during a separate deployment pass. The investigation on July 7 identified the mechanism and the consequence — the actual environment variable change came on July 16.

This was not a model reasoning failure. It was an environment alignment failure. The agent followed its instructions correctly. The instructions were based on the wrong date.

Token multiplication

A rough cost model exposed the problem:

unexpected daily cost
= background turns
× prompt/context tokens
× input price
+ output tokens
× output price

The model configuration recorded DeepSeek V4 Flash at $0.09/$0.18 per million tokens. At 56 turns per day with 17k–21k input tokens each, the daily input consumption approached one million tokens. The output side was smaller but nonzero.

The important variable was not only price. It was turn count.

An inexpensive model could still waste money if the scheduler created dozens of unnecessary full-context turns. The heartbeat multiplied cost not through an expensive model but through an expensive schedule.

The corrected schedule

After the fix, the deployment separated two concepts: heartbeats were disabled, and creative-agent work ran as named cron jobs.

heartbeat: { every: "0m" }          — disabled for butlers
--tz "Asia/Shanghai"                — cron timezone
--no-deliver                        — workspace files, not Telegram

explicit cron registration
  ├─ control-action       01:00
  ├─ control-reflection   Sunday 02:00
  ├─ writer-diary         02:00
  └─ writer-reflection    Sunday 03:00

The corrected configuration documented in the repository used every: "0m" — the explicit disable. The cron jobs were registered with --tz "Asia/Shanghai" and --no-deliver. The container timezone remained in UTC until the separate fix on July 16.

Declared versus effective runtime

The heartbeat incident illustrated a broader pattern:

repository config → installed OpenClaw version → registered cron state
       ↓                    ↓                         ↓
 intended interval       accepted schema?          actual trigger?

A file could say "disabled." A running service could still have an old job. A package could interpret the field differently than the operator expected. The absence of the every field was not the absence of a heartbeat — it was the presence of a default.

An operator needed a check at each boundary. The repository configuration, the installed package version, and the registered runtime state were three separate things that could disagree.

What to measure next time

Before deploying an autonomous schedule, the investigation suggested recording:

MeasurementWhy it matters
Number of registered jobsDetect duplicate or stale jobs
Actual trigger timestampsDetect timezone drift
Input/output tokens per turnExplain provider usage
Model and gateway versionsTie behavior to binaries
Retry countsDistinguish failures from intentional work
Delivery modePrevent cron output from being sent to the wrong channel

The project's cron setup script tried to remove and recreate named jobs, but intermittent CLI pairing failures could make duplicate detection unreliable. That remained an operational caveat.

Where things stood

By July 8, the heartbeat blocks were gone. The every: "0m" setting was in place on both butlers. Four cron jobs ran with --no-deliver and --tz "Asia/Shanghai". The husband and wife sessions were at fresh zero. Writer and maker were producing in-character content.

The container still ran in UTC. The system prompt still injected the wrong date to cron-driven agents. The intended fix — adding TZ=Asia/Shanghai to the Docker Compose environment — had been identified but not yet applied. The agents were writing files with yesterday's filename, overwriting entries that should have been preserved.

The heartbeat was gone. The timezone mismatch it had helped reveal was not yet fixed. The difference between identifying a problem and deploying its correction was, in this case, nine days.

Next: "Running four AI agents on pocket change."