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
- The
clapilot-agentruntime executes an agent turn in a dedicated session (heartbeat:user:<userId>orheartbeat:teamchat) with the configured instructions and, optionally, recent chat history from the target chat as read-only context. - 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.
- The final reply is gated by the
NO_MESSAGEtoken — 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_sessiondelivery, or the teamchat room) withmessage_origin = 'assistant_heartbeat'. - Reply contains substantial content alongside the token → the token is stripped and the content is delivered.
- Reply is exactly
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.
| Field | Meaning |
|---|---|
enabled | Master toggle. Off means the runtime disables the job entirely. |
intervalMinutes | How often the heartbeat runs (5 min – 7 days, default 60). |
instructions | Free-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. |
historyLimit | How many recent messages from the target chat are included as context (0–50, default 15; 0 = none). |
model | Optional model ref override for heartbeat runs (picker backed by the same model list as the chat UI); null = configured primary/fallback model routing. |
dndStart / dndEnd | Optional 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. |
dndTimezone | IANA 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 runWATCH[24h]: <what>/WATCH[3d]: <what>— keep watching but snooze rechecking for that durationRESOLVE: <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'sPOST /internal/heartbeat/triggerand returns{ ok, delivered, suppressed, text, outputPreview, runId }.- Delivery reuses
POST /api/agent-runtime/assistant-messagewithmessageOrigin: "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 signalsNO_MESSAGEand 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_MESSAGEwhen 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.
