Clapilot-Agent Heartbeat

Recurring proactive agent checks per user and for the teamchat, with strict NO_MESSAGE suppression.

The heartbeat lets Clapilot check in proactively instead of only answering when asked: on a schedule, the agent runs a background turn, verifies whether anything needs attention right now, and posts at most one proactive chat message — by default none. Each user can configure a personal heartbeat, and admins can configure one teamchat heartbeat. This is the v2 implementation; the earlier heartbeat subsystem was removed and its legacy native_heartbeat_* settings columns are intentionally ignored.

What a heartbeat run does

  1. The clapilot-agent runtime executes an agent turn in a dedicated session (heartbeat:user:<userId> or heartbeat:teamchat) with the configured instructions and, optionally, recent chat history from the target chat as read-only context.
  2. The agent decides whether anything genuinely needs attention right now, using tools to verify facts during the run. Findings from earlier runs must be re-verified, never repeated blindly.
  3. The final reply is gated by the NO_MESSAGE token — a sentinel word the model is instructed to reply with when there is nothing worth posting:
    • Reply is exactly NO_MESSAGE (or the token plus trivial filler under 20 characters) → nothing happens: no chat message, no notification.
    • Reply contains real content → it is posted to the target (personal chat via main_session delivery, or the teamchat room) with message_origin = 'assistant_heartbeat'.
    • Reply contains substantial content alongside the token → the token is stripped and the content is delivered.
Stored configs user_profiles / app_settings JSON Reconcile loop agent_jobs rows with job_type = 'heartbeat' Due job: agent turn session heartbeat:user:<id> or heartbeat:teamchat NO_MESSAGE gate token check on final reply Suppressed no message, no notification Posted to target chat message_origin = assistant_heartbeat token only real content

Configuration

Settings UI: Profil → Heartbeat (per user) and Admin → Teamchat-Heartbeat (admin only). Both panels offer a manual "Run now" trigger that executes immediately, reports whether a message was posted or suppressed, and does not shift the regular schedule.

FieldMeaning
enabledMaster toggle. Off means the runtime disables the job entirely.
intervalMinutesHow often the heartbeat runs (5 min – 7 days, default 60).
instructionsFree-text operator instructions executed on every run (max 8000 chars).
sessionId (user scope)Target chat session; null = main chat. History is read from and messages are posted to this session.
roomId (teamchat scope)Teamchat room; null = default clapilot-members (#general).
actingUserId (teamchat scope, server-set)The admin who saved the settings; the teamchat heartbeat runs with this user's Clapilot tool scope (same pattern as scheduled_tasks.created_by). Configs saved before this field existed fall back to the oldest admin account.
historyLimitHow many recent messages from the target chat are included as context (0–50, default 15; 0 = none).
modelOptional model ref override for heartbeat runs (picker backed by the same model list as the chat UI); null = configured primary/fallback model routing.
dndStart / dndEndOptional quiet-hours window ("HH:mm", both required; overnight windows like 22:00–07:00 supported). While inside the window, scheduled runs are skipped entirely (no agent turn, no tokens) and deferred to the window's end. The manual "Run now" trigger deliberately bypasses quiet hours.
dndTimezoneIANA timezone the quiet-hours window is interpreted in (default Europe/Berlin; not exposed in the UI).

Storage:

  • Per user: user_profiles.heartbeat_config_json (migration 180)
  • Teamchat: app_settings.teamchat_heartbeat_config_json (migration 180)

Watchlist

Each heartbeat scope has a persistent watchlist — items to re-check on later runs ("waiting for a reply from X", "check whether invoice 4711 was paid"). The model manages it through directive lines in its final reply, which the runtime parses and strips before the NO_MESSAGE gate (the same final-reply-protocol pattern as the automation notify prefix):

  • WATCH: <what to check> — keep watching, re-verify on every run
  • WATCH[24h]: <what> / WATCH[3d]: <what> — keep watching but snooze rechecking for that duration
  • RESOLVE: <short-id> — remove an open item (ids are shown in the run prompt)

NO_MESSAGE plus WATCH: lines therefore stores items without posting anything. Open items are injected into every run prompt with the explicit rule that they are reminders to re-verify, never facts to repeat (the v1 stale-notes failure mode). Guardrails: max 20 items per scope, 500 chars each, mandatory auto-expiry after 14 days, duplicate adds refresh the existing item instead of duplicating it. Storage: heartbeat_watch_items (migration 182). The settings panels show the current watchlist and let users delete items directly (DELETE /api/heartbeat/watchlist?scope=...&id=...).

Scheduling model

The runtime module services/clapilot-agent/src/jobs/heartbeat.mjs declaratively reconciles agent_jobs rows (job_type = 'heartbeat') from the stored configs: instead of syncing at save time, a poll loop (default every 30 s, CLAPILOT_AGENT_HEARTBEAT_POLL_MS; reconciliation on every second tick) continuously makes the job table match the stored configs. Enabling, disabling, or editing a heartbeat therefore takes effect within about a minute. Config edits never postpone an already-due run; shortening the interval takes effect immediately.

Runs are guarded by PostgreSQL advisory locks (application-level locks held only for the duration of the run), so concurrent pollers can never double-post. Failures are recorded on the job row (last_error, last_delivery_status = 'error'), surface only in the settings UI status panel, and the next attempt simply happens at the next interval — no retry storms, no failure messages in chat.

If the runtime restarts while an idempotent heartbeat turn is executing, the run reconciler requeues its persisted recovery envelope on the same agent_runs row at the next boot. Legacy heartbeat runs without an idempotency key or recovery envelope are marked failed (error_code = 'orphaned_restart'); the heartbeat schedule itself remains untouched.

API

  • GET/POST /api/heartbeat/settings?scope=user|teamchat — read/save config plus job status (lastRunAt, nextRunAt, lastDeliveryStatus: delivered / suppressed / error). Teamchat scope requires the admin role.
  • POST /api/heartbeat/trigger — run the heartbeat immediately ({ scope }); proxies to the runtime's POST /internal/heartbeat/trigger and returns { ok, delivered, suppressed, text, outputPreview, runId }.
  • Delivery reuses POST /api/agent-runtime/assistant-message with messageOrigin: "assistant_heartbeat".

Design notes (lessons from v1)

  • Suppression is token-based, not length-based. v1 suppressed anything under 300 chars after stripping HEARTBEAT_OK, which could silently swallow short real alerts. v2 only suppresses when the model signals NO_MESSAGE and wrote nothing meaningful besides it, or when the output is empty.
  • Unchanged state is re-suppressed. The system prompt instructs the model to reply NO_MESSAGE when a check finds the same situation it already posted about earlier, even if that situation is bad — a new post requires that something changed, resolved, worsened, or became newly due.
  • Dedicated sessions per scope. User and teamchat heartbeats never share session state, avoiding the v1 cross-scope config/session leaks.
  • No stale-blocker carryover. The system prompt requires re-verification of any finding within the current run.
  • Automatic delivery only. The agent is instructed never to post the heartbeat result through manual channel tools; the runtime delivers the final reply itself, which prevents duplicates.