Skip to content

Heartbeat & lifecycle hooks

The HEARTBEAT.md engine with two-phase decisions and adaptive intervals, plus the hooks/event pipeline.

Revka has two complementary mechanisms for acting between user messages and observing every action it takes. The heartbeat engine runs a periodic background loop inside revka daemon that reads a user-managed HEARTBEAT.md task file and decides — optionally with an LLM — which tasks to run on each tick. The hooks / event pipeline is a middleware layer that lets builtin handlers (and future plugins) intercept and react to lifecycle events such as tool calls, LLM requests, and channel messages.

Reach for the heartbeat when you want Revka to check in, monitor something, or run recurring agentic tasks autonomously. Reach for hooks when you need audit logging, compliance delivery, or programmatic interception of the agent loop. Both are configured in ~/.revka/config.toml and both run as part of the supervised daemon, so they restart automatically on failure.

The heartbeat is a periodic loop that, on each tick:

  1. Reads HEARTBEAT.md from the workspace and collects the runnable tasks (filtering out paused and completed entries, sorted by priority).
  2. Recalls relevant memories and, optionally, recent channel history.
  3. In two-phase mode, asks the LLM whether any tasks are worth running right now (Phase 1) before executing the selected ones (Phase 2).
  4. Executes the chosen tasks and delivers the results to a configured channel.
  5. Records the run in a local SQLite history database and emits a HeartbeatTick observer event / revka_heartbeat_ticks_total metric.

The engine is the component behind the heartbeat entry in the component health registry (GET /health) and the daemon → heartbeat-freshness check in revka doctor (stale after 30 seconds).

Two-phase mode (two_phase = true, the default and recommended setting) splits each tick into two LLM interactions:

  • Phase 1 — decide. Revka presents the runnable tasks to the model and asks which, if any, to run. The model replies with run: 1,2,3 to select task indices, or skip to run nothing.
  • Phase 2 — execute. Only the selected tasks are executed agentically.

This saves API cost during quiet periods: on a tick where nothing is worth doing, you pay for one small decision call instead of executing every active task. Setting two_phase = false skips Phase 1 entirely and executes every active task on every tick — simpler, but more expensive and noisier.

With adaptive = false (default), the heartbeat fires every interval_minutes. With adaptive = true, the interval flexes between min_interval_minutes and max_interval_minutes:

  • Back-off on failure. After consecutive failures the next interval grows exponentially: interval = base * 2^consecutive_failures, capped at max_interval_minutes.
  • Speed up for urgent work. When a high-priority task is pending, the interval is pinned down to max(min_interval_minutes, 5) so urgent items aren’t left waiting for the next slow tick.

The engine tracks runtime stats in HeartbeatMetrics: uptime_secs, consecutive_successes, consecutive_failures, last_tick_at, avg_tick_duration_ms (an exponential moving average, α = 0.3), and total_ticks.

The dead-man’s switch is a watchdog that alerts you when the heartbeat stops ticking — the canonical signal that the daemon has silently died or wedged. If no tick occurs within deadman_timeout_minutes, an alert is delivered to deadman_channel / deadman_to. Set deadman_timeout_minutes = 0 (the default) to disable it.

[heartbeat]
enabled = true
deadman_timeout_minutes = 90 # alert if no tick within 90 minutes
deadman_channel = "telegram"
deadman_to = "123456789"

Inside revka daemon, the heartbeat runs as one of the four supervised components (alongside the gateway, channels, and scheduler). It loads heartbeat tasks from the workspace, recalls memories, optionally runs the Phase 1 decision call, executes the selected tasks, delivers results to the configured channel, and writes run history under ~/.revka/workspace/heartbeat/.

A few behaviors worth knowing:

  • Delivery auto-detection. If target / to are not set, the component auto-detects the first Telegram entry in allowed_users as the delivery target.
  • Session context. With load_session_context = true, the engine injects the last 20 messages from the delivery target’s session file into each task prompt; it skips injection when there are no user messages.
  • Latency tradeoff. Two-phase mode adds the latency of the decision call but reduces unnecessary executions.

Because it runs under the daemon’s component supervisor, a crash in the heartbeat triggers an exponential-backoff restart and bumps the restart count in the component health snapshot — it does not take down the rest of the daemon.

On first run, ensure_heartbeat_file() creates a default HEARTBEAT.md in the workspace with example tasks. An existing file is never overwritten, so it is safe to edit by hand. Each task is a Markdown list item:

# Periodic Tasks
- [high] Check email for urgent messages
- Review my calendar for today
- [low|paused] Check the weather forecast
- [completed] Old task

Each line follows - [priority|status] task text, where the bracketed prefix is optional:

FieldValuesDefaultMeaning
priorityhigh, medium, lowmediumHigher-priority tasks sort first and (in adaptive mode) pull the interval down.
statusactive, paused, completedactivepaused and completed tasks are excluded from each tick.

A bare - task text line is treated as medium priority and active status. You can combine both fields in the brackets, separated by | (for example [low|paused]). collect_runnable_tasks() filters out everything that is paused or completed, then sorts the remainder by descending priority.

If no tasks are defined, the engine falls back to the configured message (for example "Check in and summarize status") so a tick still produces a useful check-in.

Each tick’s execution history is stored in SQLite at <workspace>/heartbeat/history.db. Per-task output is truncated at 16 KB. The max_run_history config key controls how many records are retained; older records are pruned in the same transaction that writes a new one.

Configure the engine under the [heartbeat] section of ~/.revka/config.toml:

[heartbeat]
enabled = true
interval_minutes = 60 # base interval between ticks
two_phase = true # ask the LLM what to run first (recommended)
adaptive = true # flex the interval; back off on failure
min_interval_minutes = 5 # adaptive floor
max_interval_minutes = 240 # adaptive ceiling
load_session_context = true # inject recent chat history into task prompts
max_run_history = 100 # SQLite run records to keep
message = "Check in and summarize status" # fallback task if HEARTBEAT.md is empty
target = "telegram" # delivery channel
to = "123456789" # recipient (user ID / channel ID)
deadman_timeout_minutes = 0 # 0 = disabled
deadman_channel = "telegram"
deadman_to = "123456789"
KeyTypeDefaultMeaning
enabledboolfalseMaster switch for the heartbeat component.
interval_minutesint30Base interval between ticks.
two_phasebooltrueRun the Phase 1 LLM decision before executing tasks.
adaptiveboolfalseEnable adaptive interval back-off / speed-up.
min_interval_minutesintAdaptive floor (and the pinned interval for high-priority tasks).
max_interval_minutesintAdaptive ceiling / exponential back-off cap.
load_session_contextboolfalseInject the last 20 messages from the delivery target into prompts.
max_run_historyint100Number of run records kept in the SQLite history DB.
messagestringFallback task text when HEARTBEAT.md has no runnable tasks.
targetstringautoDelivery channel (auto-detects Telegram allowed_users[0] if unset).
tostringautoDelivery recipient (user ID / channel ID).
deadman_timeout_minutesint0Alert if no tick within this window; 0 disables the switch.
deadman_channelstringChannel for dead-man’s-switch alerts.
deadman_tostringRecipient for dead-man’s-switch alerts.

The hooks system is a priority-ordered middleware pipeline that intercepts the agent’s lifecycle events. It is the extension point behind builtin handlers (command logging and webhook audit) and is designed to support plugin authors. There are two kinds of hooks:

  • Void hooks — fire-and-forget notifications, dispatched in parallel. They cannot change anything; they observe. Examples: on_gateway_start, on_gateway_stop, on_session_start, on_session_end, on_llm_input, on_llm_output, on_after_tool_call, on_message_sent, on_heartbeat_tick.
  • Modifying hooks — run sequentially in descending priority order, with each hook’s output piped into the next. They can mutate the value or abort the event. Examples: before_model_resolve, before_prompt_build, before_llm_call, before_tool_call, on_message_received, on_message_sending.

A modifying hook returns a HookResult<T>: Continue(value) to pass the (possibly modified) value down the chain, or Cancel(reason) to abort. The first Cancel short-circuits the chain — remaining modifying hooks for that event do not run. Void hooks always all run regardless.

Ordering is controlled by each handler’s priority(): higher values run earlier in the modifying chain (default is 0). Hooks are panic-safe — a panic inside a handler is caught and does not crash the agent.

CommandLoggerHook is a builtin handler that logs every tool call to structured tracing::info! output after the call completes, recording the tool name, duration in milliseconds, and success flag:

[14:32:07] Bash (412ms) success=true

It runs at priority -50 (after most other hooks) and needs no configuration. Use it for lightweight, dependency-free audit logging — it pairs well with the verbose observer backend for interactive sessions, or any tracing subscriber (for example RUST_LOG=info revka daemon). For where these lines surface in a deployment, see Observability & tracing.

For centralized audit, SIEM ingestion, or compliance logging, WebhookAuditHook POSTs a JSON payload to an external HTTPS endpoint for every tool call that matches a configured glob pattern. It runs at priority -100 (always last) and is fire-and-forget — delivery failures are logged but never propagate to the agent.

Configure it under [hooks.webhook_audit]:

[hooks.webhook_audit]
enabled = true
url = "https://audit.example.com/webhook"
tool_patterns = ["Bash", "Write", "mcp__*"]
include_args = true
max_args_bytes = 4096
KeyTypeDefaultMeaning
enabledboolfalseMaster switch for the audit hook.
urlstringDestination endpoint. Must be https:// (see security note).
tool_patternslist[]Glob patterns for which tools to audit. * is a wildcard anywhere (mcp__*, *_write, mcp__*__create).
include_argsboolfalseInclude tool arguments in the payload.
max_args_bytesint4096Max serialized argument bytes to include; 0 = unlimited.

The POST body looks like:

{
"event": "tool_call",
"timestamp": "2026-06-18T14:32:07Z",
"tool": "Bash",
"success": true,
"duration_ms": 42,
"error": null,
"args": { "command": "git status" }
}

When include_args = false (or no captured arguments exist for the call), args is null. Arguments are captured in before_tool_call and consumed in on_after_tool_call.

The [hooks] section holds lifecycle-hook settings and builtin-hook toggles. The webhook audit hook lives under [hooks.webhook_audit] (above). CommandLoggerHook is auto-registered when hooks are active and has no config keys of its own.

[hooks]
[hooks.webhook_audit]
enabled = false
url = "https://audit.example.com/webhook"
tool_patterns = ["Bash", "Write", "mcp__*"]
include_args = false
max_args_bytes = 4096