Clapilot-Agent Heartbeat

Recurring proactive checks for users, teamchat, and opt-in self-acting specialized agents.

The heartbeat lets Clapilot check in proactively instead of only answering when asked. User and teamchat scopes may post at most one proactive chat message — by default none. A third agent scope powers opt-in Self Acting Agents: each enabled specialized agent periodically works its own shared Aufgaben board without posting heartbeat output to chat. 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>, heartbeat:teamchat, or heartbeat:agent:<specializedAgentId>) with the scope-specific instructions and 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)
  • Self-acting specialized agent: specialized_agents.self_acting_config_json (migration 285). This scope uses intervalMinutes, instructions, historyLimit, teamChatScanEnabled (default true), and reportChannelId (default null); it has no quiet-hours or delivered-chat-history settings.

Self-acting specialized-agent scope

Enabling Self Acting Agent on an enabled specialized agent creates or reuses a dedicated shared Aufgaben board and declaratively reconciles one heartbeat job for it. Each run reloads the full specialist definition, uses the specialist's default model and permission envelope, and passes personalMemoryEnabled into executeRun, so its existing isolated personal memory applies automatically. Approved personal memories are also projected into that specialist's isolated Knowledge Graph (scope_key='agent:<id>') and are reachable through the knowledge_* tools only during its own runs. The prompt contains the configured heartbeat instructions, up to historyLimit open board tasks ordered by priority and due date, and that agent's watchlist.

When teamChatScanEnabled is true, the heartbeat also reads new messages from every non-archived Team Chat room to which the specialist is invited. chat_room_specialized_agents.heartbeat_scanned_at stores the cursor independently for each room/agent pair. A first scan is bounded to the previous 24 hours, each room contributes at most 30 messages per heartbeat, and the shared prompt character cap stops before overflow; cursors advance only through the last message actually included and only after executeRun succeeds. A successful first scan with no messages initializes its cursor so the same empty 24-hour window is not re-read indefinitely.

The specialist judges those unscanned messages against its role and system prompt. Matching work is claimed by calling aufgaben_create_task on its dedicated board with source_type="team_chat" and source_id=<message id>. The native execution path returns the existing task instead of inserting again when that source is already claimed, and a partial unique index on Team Chat source ids closes concurrent-agent races. Messages outside the specialist's role are ignored, and the heartbeat never replies in the scanned Team Chat channels.

When reportChannelId names a non-archived Team Chat channel (channel_public or channel_private), the heartbeat prompt additionally instructs the specialist to post short status updates to that channel via team_chat_post_message: when it picks up or claims a task, when it finishes one or hits a blocker, and when it makes significant progress. team_chat_post_message is implicitly granted for these runs, so admins do not need to add it to the tool allowlist separately. The instructions require brief, factual updates and forbid posting when nothing happened in the run; an invalid, archived, or deleted channel id disables the report section silently. Status posts carry the specialist's identity meta (assistantAgentId / handle / name / profile image), so they render as the agent in Team Chat.

The run is classified as an automation through clientContext.automationId = "self-acting-agent:<agentId>", so autonomous completion judging and caveat safeguards apply. The heartbeat's final reply is still never delivered to chat: the runtime only applies WATCH: / RESOLVE: directives, marks due watch items checked, advances successful scan cursors, and records the run outcome. Chat visibility comes exclusively from the explicit, tool-driven report-channel posts described above. Its per-agent Knowledge Graph stays excluded from workspace builds, Wiki, Dreaming, user runs, and every other specialist.

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"). User items are keyed by user_id, teamchat items have no user, and agent-scope items are keyed by specialized_agent_id. The model manages the list through directive lines in its final reply, which the runtime parses and strips before delivery handling (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.

Heartbeat provider calls stream internally so the provider deadline measures inactivity instead of total response time. They retain the background-workload retry and fallback policy, including per-provider and per-model timeout overrides for slower self-hosted deployments. A successful side-effecting tool boundary is checkpointed before another provider turn can run, so a later stall continues from durable progress rather than replaying the completed action.

The operational heartbeat SLO is fewer than 1% failed scheduled runs in a rolling 24-hour window. A timeout regression is not considered resolved until the affected production heartbeat has completed a full 24-hour observation window without the reported timeout signature; implementation and merge alone are not closure evidence.

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.

Successful side-effecting tool steps are checkpointed durably as they finish. A fresh surviving progress checkpoint is consumed after a process restart, while a final provider stall makes the run resumable timed_out state instead of an unrecoverable failure. The scheduler uses its normal interval or provider-quota backoff before continuing from the bounded durable successful-action-key list (latest 100 entries; latest 20 shown to the model) and open todo labels. During that heartbeat continuation, an exact replay returns its stored result without executing the action again only once per checkpoint entry and only when the original result carries durable mutation or outbound-delivery evidence. Generic shell/package execution (exec_command, shell_command, and package_install), internal agent_todo_update bookkeeping, unconfirmed mutations, failed calls, subsequent legitimate identical actions, and non-heartbeat turns are never deduplicated. Pending recovery expires after 24 hours, and a manual check cannot consume a scheduled continuation. The recovery instruction is not stored in conversation history, and successful completion clears the progress and recovery checkpoints after the final heartbeat output records recovered progress and whatever remained open.

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, teamchat, and specialized-agent 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. For user and teamchat scopes the agent is instructed never to post the heartbeat result through manual channel tools; the runtime delivers the final reply itself, which prevents duplicates. The agent scope inverts this: its final reply is never delivered, and the only chat output is the deliberate team_chat_post_message status posts to a configured report channel.