What does an agent's heartbeat do when nobody is watching?
A line in HEARTBEAT.md, a tick every 30 seconds and a condition that asks the target system first: how a scheduled agent runs, and where it stops.
An agent that only works when spoken to is a chat window with extra bookkeeping. To check by itself whether anyone answered its merge request, it needs a schedule. Somebody on Hacker News asks exactly that (news.ycombinator.com/item?id=48028035). Most LLM agents are reactive and only work when prompted, and whether the answer is a cron job, custom middleware or something else stays open. Behind the question sits a sum. A schedule is cheap and an agent run is expensive, so coupling them without a check buys a full run every five minutes for the news that nothing was waiting.
A Show HN project therefore advertises scheduled agents that cost nothing on a quiet day (item?id=49475656). Ahead of the run sits a guard command without a model; if it prints nothing, the agent stays asleep.
One line in HEARTBEAT.md holds the schedule
Behaviour of a covey agent lives in versioned Markdown files, SOUL.md for role and tone, HEARTBEAT.md for the recurring work. HEARTBEAT.md is platform config rather than prompt material: it is parsed on save instead of being compiled into the system prompt. One line in it looks like this:
- alle: 15m nur-wenn: gitlab:mr titel: Merge Requests betreuen aufgabe: Prüfe deine offenen Merge Requests auf neues Review-Feedback, arbeite es ein und reagiere auf Merge oder Close.
Exactly one of the two schedule forms stands per entry, alle: for an interval such as 30m, 2h or 1d, täglich: for a time of day in server time. The parser is strict here: a recognised line without titel: or without exactly one schedule comes back as an error, because a typo swallowed in silence would mean the job never runs again.
On save the entries are materialised into the table agent_heartbeats, created in migration 0013, where last_fired_at starts at now(). A freshly created heartbeat therefore fires only once its interval has elapsed.
The schedule itself runs without a model
Every 30 seconds by default, tunable with COVEY_TICK_INTERVAL, the control plane asks what is due, and that question is pure SQL. The interval form is due once last_fired_at plus its interval is reached, the daily form once a day from its configured time.
A due entry becomes an ordinary backlog task with origin='heartbeat' and runs the usual chain, sandbox up, work, record the result, sleep again.
Before that, nur-wenn: can put a question in the way. It names a target system, and the control plane asks that plugin through the target.WorkChecker interface whether work is lying there, for email whether unread mail is waiting. Secrets it resolves itself and keeps.
A colon narrows the scope, gitlab:mr for the review loop and gitlab:issues for issue triage, so two heartbeats of the same system fire separately. If the target reports no work the run is dropped, while last_fired_at advances anyway and the schedule keeps its rhythm.
That check is fail-open. A plugin without a WorkChecker, missing secrets or a broken connection all let the heartbeat fire as usual.
Silence becomes a valid answer
A nur-wenn: condition measures a level, and "work is waiting there" stays true as long as the state holds. On a merge request the same comment wakes the agent at every interval while the last contribution came from somebody else.
Say the agent ends a run deliberately without commenting, because the QA colleague's feedback was an approval. It would then comment only to switch off its own alarm clock. Between two agents that carries a runaway: every comment closes one gate and opens the other.
So plugins deliver a signature of the work they found alongside the yes or no. For GitLab that is the project, the item number and the highest note id per waiting thread. Kept in agent_heartbeats.last_work_sig and added in migration 0038, it fires the heartbeat again only once it has changed. The agent is woken for every piece of news and never twice for the same one.
The signature advances only after a run that wrote something itself, and plugins report through target.SignatureWriter which actions can move it. For GitLab that covers everything producing a note: assignment, label, approval, push and merge. A run without a write of its own leaves the watermark, so the next tick fires for whatever arrived meanwhile.
One gap stays open.
A foreign contribution to exactly the thread the agent commented on is absorbed into the watermark inside that window. Separating them would take the ids of the notes the agent wrote, and the recording holds the action alone.
Where the schedule stops on its own
A heartbeat keeps its runs apart. While a task of the same title from this heartbeat is still open, in progress or blocked, the heartbeat leaves it at that one. The run still counts as fired, so the schedule simply continues afterwards. Continuations of an aborted run count too: origin continuation:<task-id>, same title, because the work carries forward. Missed runs are not caught up; after a night with the control plane down, only the next due run fires.
Three states stop the schedule outright: an agent not yet hired, which the query filters with hired_at IS NOT NULL, killed on the agent and fleet_killed on the organisation.
At the turn limit the runtime reports the state incomplete and a continuation picks the same session back up; after three in a row, set in maxContinuations, the task escalates to the manager. The per-agent budget draws a second line: on exceeding its cumulative cost the platform pauses the agent through that same kill switch.
What the heartbeat does when nobody is watching is, most evenings, little: one SQL query, one question to the target system, one comparison of two signatures. The expensive part begins once something has changed.