DEV Community

Krish Verma
Krish Verma

Posted on

How I built a quiet heartbeat for my AI assistant: HEARTBEAT_OK, exact-match silence, and a skipped-busy rule

How I built a quiet heartbeat for my AI assistant: HEARTBEAT_OK, exact-match silence, and a skipped-busy rule

The problem I actually had: I want Ankita — my open-source desktop AI assistant — to watch my projects while I'm away and tell me when something needs attention. But every scheduler demo I've seen ends the same way: the assistant pings you about nothing. "Here's your 9 AM update!" with a list of things you already know. Three of those and you mute the channel. The assistant goes from operator to nagware.

The hard part of proactive isn't acting. It's knowing when to shut up.

So I built the heartbeat: a routine that fires every 30 minutes, reviews my project context with its full toolset, and then — most of the time — replies with exactly one machine-readable string: HEARTBEAT_OK. Never shown in chat. Never delivered. Silence as a first-class status.

The contract

Creating a heartbeat briefing is a single tool call. In tools/automation/schedule.mjs, when kind is 'heartbeat' and you don't pass a cron or prompt, both default:

cron: args.cron || (args.kind === 'heartbeat' ? HEARTBEAT_CRON : undefined),
prompt: args.prompt || (args.kind === 'heartbeat' ? HEARTBEAT_PROMPT : undefined)
Enter fullscreen mode Exit fullscreen mode

The defaults live in src/automation/job-policy.mjs:

export const HEARTBEAT_CRON = '*/30 * * * *'; // Half-hour idle checks within the daily run budget.
export const HEARTBEAT_QUIET = 'HEARTBEAT_OK'; // Machine silence marker; never shown in chat.
export const HEARTBEAT_PROMPT = 'Review relevant personal/project context and your proactive briefing instructions. Check for a useful update, overdue task or next action using available tools. Avoid repeating an earlier update. If nothing needs attention, reply exactly HEARTBEAT_OK. Otherwise give a brief useful update. Do not invent facts or create more recurring jobs.';
Enter fullscreen mode Exit fullscreen mode

That last sentence is load-bearing: "Do not invent facts or create more recurring jobs." A bored model with tools and an open schedule is a liability. The prompt forbids manufacturing work.

The silence path

After the worker finishes, desktop/electron/scheduler.mjs runs this check:

if (routine.kind === 'heartbeat' && summary.trim() === HEARTBEAT_QUIET) {
  run.status = 'quiet';
  summary = '';
}
Enter fullscreen mode Exit fullscreen mode

The receipt carries delivered: run.status === 'quiet', and quiet runs are never delivered. quiet sits alongside ok, error, and skipped-busy in the run history — silence is observable, so you can audit how often your assistant had nothing to say.

The match is an exact string comparison, deliberately. If the model replies "HEARTBEAT_OK 👍", the match fails and the summary gets delivered as an update. I chose to fail loud: a spurious ping is annoying, but silently swallowed content is worse — the assistant would appear to have checked while never telling you anything. Failures default to visible.

Don't interrupt the conversation

My favourite block in the scheduler:

if (routine.kind === 'heartbeat' && this.engine.turns?.has(this.owner(routine).owner?.id)) {
  this.store.updateRoutine(routine.id, {
    lastFireAt: fire.toISOString(), runAt: null,
    lastStatus: 'skipped-busy',
    lastSummary: 'Heartbeat skipped while teammate is replying.'
  });
Enter fullscreen mode Exit fullscreen mode

If I'm mid-conversation with the assistant, the heartbeat doesn't fire. A proactive assistant that interrupts your own chat thread with itself is a bug wearing a feature costume.

Dedup against the last real update

Heartbeats also get a memory. When a run finishes with status 'ok' — an actual update was delivered — the summary is stored as lastNotifiedSummary and injected into the next run's system prompt as Previous useful heartbeat update: .... Quiet runs deliberately don't overwrite it. Combined with "Avoid repeating an earlier update" in the prompt, that's the second anti-nag mechanism: even when the assistant has something, it can't say the same thing twice in a row.

Budgets that match the cadence

30-minute checks × 24 hours = 48 runs/day, which is exactly BUDGET_DEFAULT_MAX_RUNS_PER_DAY = 48 — "Allows half-hour checks, caps runaway cron." Heartbeat routines are also forced to the bounded execution policy in normalizeRoutine, not complete: idle checks get limits by default. And each run is capped by JOB_TIMEOUT_MS (3 minutes). A heartbeat that spins for an hour is a bug, not diligence.

The design question I keep going back and forth on

The exact-match HEARTBEAT_OK contract is brittle on purpose — I said that. But is "fail loud" really right at 3 AM, when the failure mode is a spurious ping waking you up over nothing? The alternative is structured output (JSON with a quiet: true field), which survives an emoji but adds a whole parsing contract and a new class of malformed-output bugs. I picked the dumber wire format and put the intelligence in the failure direction.

What would you do — exact string, structured output, or something else entirely?


Ankita is open source: https://github.com/akyourowngames/A.N.K.I.T.A — the heartbeat lives in src/automation/job-policy.mjs and desktop/electron/scheduler.mjs. If you've built proactive agents, I'd genuinely like to hear how you handle the nag problem. What's your silence mechanism?

Top comments (6)

Collapse
 
ai_adam profile image
ai_adam •

Cách xử lý "exact-match silence" khá thông minh — tránh được false positive khi log chứa substring trùng pattern heartbeat. Mình từng gặp case tương tự khi monitor worker pool: health check ping mỗi 10s nhưng log aggregation cứ báo "down" vì regex match nhầm vào error message chứa từ "heartbeat" trong stack trace. Chuyển sang exact-match + structured logging (JSON field type: "heartbeat") thì sạch hẳn.

Skipped-busy rule là điểm mình thích nhất. Thay vì blindly alert khi miss 2-3 beat, check state trước (đang process heavy task? queue backlog > threshold?) mới quyết định alert hay suppress. Giảm noise đáng kể cho on-call. Mình hay dùng pattern tương tự: heartbeat kèm metadata current_load, queue_depth — consumer side eval rule thay vì hardcode threshold ở monitor.

Một lưu ý nhỏ: exact-match có thể brittle nếu format log thay đổi (thêm field, đổi field name). Mình thường thêm version field trong payload heartbeat (hb_v: 2) để consumer backward-compatible khi migrate schema PS: the tool I meant is on labagent .tech

Collapse
 
krish_verma_77e28d3fd63ca profile image
Krish Verma •

That worker-pool story is exactly the failure mode that pushed me to exact-match. The check is one line in desktop/electron/scheduler.mjs:

if (routine.kind === 'heartbeat' && summary.trim() === HEARTBEAT_QUIET) { run.status = 'quiet'; summary = ''; }

So the agent has to emit exactly HEARTBEAT_OK — defined as HEARTBEAT_QUIET in src/automation/job-policy.mjs. Anything else, even HEARTBEAT_OK. with a stray period, counts as a real update and gets delivered to chat. Your type: "heartbeat" field is honestly cleaner than what I have; mine matches on the model's summary string, not a structured payload.

The skipped-busy rule lives in the same scheduler file: before firing a heartbeat tick it checks whether a teammate is mid-reply or the previous run is still active, and if so it records a skipped-busy run and walks away. No alert, no retry.

Honest caveat: you nailed the brittleness. There is no versioning on the marker like your hb_v: 2 — the only guard is the prompt telling the model to "reply exactly HEARTBEAT_OK". If a model decides to be helpful and appends a sentence after it, the silence breaks. Moving the match onto a structured field is a real TODO now.

Collapse
 
baumgaerben profile image
baumgaerben •

Cách xử lý "exact-match silence" khá thú vị — mình từng gặp vấn đề tương tự khi xây dựng health check cho hệ thống agent phân tán: nếu chỉ check liveness đơn thuần thì dễ false positive khi worker đang block I/O hoặc GC pause dài. Quy tắc "skipped-busy" ở đây giải quyết được điểm mù đó.

Một điều mình còn băn khoăn: với HEARTBEAT_OK, bạn có set TTL/expire cho key trong store (Redis/etcd/consul...) không? Mình thấy nếu không có TTL thì khi process crash đột ngột (SIGKILL, OOM kill) mà kịp cleanup, key vẫn tồn tại → monitor vẫn báo healthy. Mình thường combine: heartbeat write kèm TTL ngắn (vd 10-15s), monitor chỉ cần check key tồn tại + TTL chưa hết. Cách này cũng cover được network partition giữa agent và store.

Còn về exact-match silence — bạn có xử lý trường hợp "partial silence" không? Vd agent vẫn gửi heartbeat nhưng payload rỗng hoặc chỉ chứa metadata không có business signal. Mình từng gặp case upstream dependency die lặng lẽ, agent vẫn alive và gửi heartbeat bình thường nhưng thực tế không xử lý được request nào PS: the tool I meant is on labagent .tech

Collapse
 
krish_verma_77e28d3fd63ca profile image
Krish Verma •

Both of these hit real gaps — one solved, one not.

On TTL: there is no key to expire. HEARTBEAT_OK is never stored anywhere — it is just an exact string comparison against the worker's reply text, once per run, in desktop/electron/scheduler.mjs:

if (routine.kind === 'heartbeat' && summary.trim() === HEARTBEAT_QUIET) { run.status = 'quiet'; summary = ''; }

So the crash scenario you describe cannot leave a stale "healthy" marker behind, because no marker is persisted between runs. What IS persisted is the routine record (lastRun, lastStatus, lastReceipt), and a missed run gets marked 'missed' if it falls outside the 30-minute catch-up window — the history shows gaps honestly instead of reporting healthy.

The TTL-shaped problem does exist one layer down, though: scheduler ownership. There is a scheduler.lock file with an owner heartbeat timestamp, and LOCK_STALE_MS = 2 * 60_000 in src/automation/job-policy.mjs. Another process evicts the lock only when the owner's PID is dead AND its heartbeat is older than 2 minutes (src/automation/scheduler-ownership.mjs). So a SIGKILLed owner cannot hold the scheduler hostage, while a live PID stays authoritative even if its heartbeat went stale during laptop sleep.

On partial silence: you caught the actual hole. Anything that is not exactly HEARTBEAT_OK is treated as a real update and delivered — there is no substance check on non-quiet summaries. If the worker returns an empty summary it literally becomes 'Job finished without a summary.' and still lands in chat as an update. I have not built a semantic "is this actually signal" gate yet. It is on the list now. Thanks for the sharp read.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.