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 (0)