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.
Heartbeat engine
Section titled “Heartbeat engine”The heartbeat is a periodic loop that, on each tick:
- Reads
HEARTBEAT.mdfrom the workspace and collects the runnable tasks (filtering outpausedandcompletedentries, sorted by priority). - Recalls relevant memories and, optionally, recent channel history.
- In two-phase mode, asks the LLM whether any tasks are worth running right now (Phase 1) before executing the selected ones (Phase 2).
- Executes the chosen tasks and delivers the results to a configured channel.
- Records the run in a local SQLite history database and emits a
HeartbeatTickobserver event /revka_heartbeat_ticks_totalmetric.
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 decisions
Section titled “Two-phase decisions”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,3to select task indices, orskipto 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.
Adaptive intervals
Section titled “Adaptive intervals”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 atmax_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.
Dead-man’s switch
Section titled “Dead-man’s switch”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 = truedeadman_timeout_minutes = 90 # alert if no tick within 90 minutesdeadman_channel = "telegram"deadman_to = "123456789"Heartbeat daemon component
Section titled “Heartbeat daemon component”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/toare not set, the component auto-detects the first Telegram entry inallowed_usersas 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.
HEARTBEAT.md format
Section titled “HEARTBEAT.md format”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 taskEach line follows - [priority|status] task text, where the bracketed prefix is optional:
| Field | Values | Default | Meaning |
|---|---|---|---|
priority | high, medium, low | medium | Higher-priority tasks sort first and (in adaptive mode) pull the interval down. |
status | active, paused, completed | active | paused 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.
Run history
Section titled “Run history”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.
[heartbeat] config
Section titled “[heartbeat] config”Configure the engine under the [heartbeat] section of ~/.revka/config.toml:
[heartbeat]enabled = trueinterval_minutes = 60 # base interval between tickstwo_phase = true # ask the LLM what to run first (recommended)adaptive = true # flex the interval; back off on failuremin_interval_minutes = 5 # adaptive floormax_interval_minutes = 240 # adaptive ceilingload_session_context = true # inject recent chat history into task promptsmax_run_history = 100 # SQLite run records to keepmessage = "Check in and summarize status" # fallback task if HEARTBEAT.md is emptytarget = "telegram" # delivery channelto = "123456789" # recipient (user ID / channel ID)deadman_timeout_minutes = 0 # 0 = disableddeadman_channel = "telegram"deadman_to = "123456789"| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Master switch for the heartbeat component. |
interval_minutes | int | 30 | Base interval between ticks. |
two_phase | bool | true | Run the Phase 1 LLM decision before executing tasks. |
adaptive | bool | false | Enable adaptive interval back-off / speed-up. |
min_interval_minutes | int | — | Adaptive floor (and the pinned interval for high-priority tasks). |
max_interval_minutes | int | — | Adaptive ceiling / exponential back-off cap. |
load_session_context | bool | false | Inject the last 20 messages from the delivery target into prompts. |
max_run_history | int | 100 | Number of run records kept in the SQLite history DB. |
message | string | — | Fallback task text when HEARTBEAT.md has no runnable tasks. |
target | string | auto | Delivery channel (auto-detects Telegram allowed_users[0] if unset). |
to | string | auto | Delivery recipient (user ID / channel ID). |
deadman_timeout_minutes | int | 0 | Alert if no tick within this window; 0 disables the switch. |
deadman_channel | string | — | Channel for dead-man’s-switch alerts. |
deadman_to | string | — | Recipient for dead-man’s-switch alerts. |
Hooks / event pipeline
Section titled “Hooks / event pipeline”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 (builtin)
Section titled “CommandLoggerHook (builtin)”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=trueIt 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.
WebhookAuditHook (builtin)
Section titled “WebhookAuditHook (builtin)”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 = trueurl = "https://audit.example.com/webhook"tool_patterns = ["Bash", "Write", "mcp__*"]include_args = truemax_args_bytes = 4096| Key | Type | Default | Meaning |
|---|---|---|---|
enabled | bool | false | Master switch for the audit hook. |
url | string | — | Destination endpoint. Must be https:// (see security note). |
tool_patterns | list | [] | Glob patterns for which tools to audit. * is a wildcard anywhere (mcp__*, *_write, mcp__*__create). |
include_args | bool | false | Include tool arguments in the payload. |
max_args_bytes | int | 4096 | Max 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.
[hooks] config
Section titled “[hooks] config”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 = falseurl = "https://audit.example.com/webhook"tool_patterns = ["Bash", "Write", "mcp__*"]include_args = falsemax_args_bytes = 4096