Skip to content

Heartbeat

The heartbeat is a periodic, per-user agent turn that reviews standing context (due tasks, unread notifications) against your standing instructions, then acts or stays silent. It’s Octipus’s proactive loop — the assistant checking in on its own, adapted to a multi-user platform with per-user gating, quotas, and quiet hours.

Off by default. Nothing runs until you enable it globally and a user opts in. The one proactive turn that IS on by default is the seeded weekday Daily Briefing hook — see HOOKS.md.

Every 60s cron tick, maybeRunHeartbeats (src/core/heartbeat.ts) processes each user’s due heartbeat hook through a cheap-first gate — no LLM tokens are spent until the last step:

  1. Global switch — config.heartbeat.enabled off ⇒ skip.

  2. Quiet hours — current hour in [quietHoursStart, quietHoursEnd) (evaluated in quietHoursTimezone, wraps midnight) ⇒ skip.

  3. Daily cap — user already hit maxRunsPerDay today ⇒ skip.

  4. Quota — user is out of their daily token budget ⇒ skip.

  5. Pending-work probe — any due open tasks (due_at ≤ now) or unread notifications (both DB-only), plus two external sources that each run only when enabled and fail soft to “nothing”: open pull requests of yours whose checks are failing (one gh api graphql search across every repo the CLI can see; silently empty when gh is not installed or authenticated) and calendar events starting within the lookahead window on a connected Google or Microsoft calendar (one request per connected provider). When the user turned on Watch my inbox, also new mail no agent has handled yet (see Inbox watch). Nothing in any of them ⇒ skip.

    Two rules keep those sources honest. The server’s gh identity belongs to whoever authenticated it — the operator — so the GitHub probe runs only for admin users (and a stored DENY on github/read still wins); a per-user GitHub credential is enterprise-track work. And a red PR or a meeting has no “done” a user can click, so each hook remembers what it already surfaced (triggerConfig.heartbeatSeen) and only a NEW item wakes it; the set is pruned to what the latest probe still sees, so a PR that went green and red again counts as new. A source that could not be read on a tick (gh timed out, a calendar returned 5xx) keeps its previous set rather than pruning it — “could not read” is not “cleared”. The pruning happens only when the probe runs: an item that clears and returns with the same state entirely inside quiet hours or after the daily cap is not re-surfaced, a trade-off taken so those skips stay free. One gh search is shared by every due hook on a tick, and gates run four at a time.

Only a non-empty probe spawns an orchestrated turn on the heartbeat channel (so the run is tagged origin='heartbeat' for audit), seeded with the pending checklist plus your standing instructions. Silence is the default: an empty probe costs zero tokens.

With Watch my inbox on (Settings → Heartbeat, triggerConfig.watchEmail on the user’s heartbeat hook), the probe also lists the inbox of the connected Google or Microsoft 365 mailbox (src/core/email/watch.ts):

  • Only new mail. Mail received since the watch was turned on (watchEmailSince), and never more than three days back. Gmail’s promotions, social and forums tabs are left out of the query.
  • Only mail no agent has dealt with. The email_handled table keeps, per message id, whether a heartbeat turn was shown it (surfaced) and what an agent did with it (handled, with an outcome and a one-line note). Ids and outcomes only: no sender, subject or body is stored.
  • At most ten per turn, listed in the checklist as untrusted text inside <email-list> (sender, subject, a 200-character preview), with what to do with each: nothing, a task, a short message to the owner (send_to_user), or a reply draft (save_reply_draft, saved in the mailbox, never sent). The agent then calls mark_email_handled for every message.
  • A message the turn did not close comes back after three hours, at most three times in all, so a turn that failed does not lose mail and one mail the agent cannot deal with does not wake the heartbeat forever.
  • Cost. One list request per check (plus one metadata request per new Gmail message). No model call when nothing is new.

Mail the owner deals with on the inbox page (a task, an archive, a saved draft) is recorded as handled too, so the heartbeat does not pick it up again. Sending stays behind its own permission: an unattended turn’s ASK is refused, so the agent cannot send unless the owner allowed it.

For the agent to file tasks and tell the owner, tasks/write and messaging/send need Allow (both ask by default). Telling the owner works even after the agent read a mail in full: the flow guard counts a send_to_user to the run’s own user as going nowhere else (isOwnerOnlyDelivery), while any other send after reading mail still asks.

The Heartbeat settings tab lists the last ten outcomes (“Reply draft saved — answered the invoice question”).

Standing instructions — the HEARTBEAT note

Section titled “Standing instructions — the HEARTBEAT note”

Your standing orders live in a single pinned note with slug heartbeat (reusing the notes tool/table — no new schema). Whatever you write there is prepended to every heartbeat turn. Example:

# HEARTBEAT
- If a task is overdue by more than a day, draft a plan and notify me.
- Summarize unread GitHub notifications; only ping me for failing CI on my PRs.
- Never send outbound messages without my approval.

Create/update it like any note (write_note with slug: "heartbeat"), then pin it. If the note is absent, the heartbeat still runs on the probe findings alone.

Globally (operator) — set in DB settings, or use these environment values before first-boot migration:

HEARTBEAT_ENABLED=true
HEARTBEAT_INTERVAL_MINUTES=60
HEARTBEAT_QUIET_HOURS_START=22
HEARTBEAT_QUIET_HOURS_END=7
HEARTBEAT_QUIET_HOURS_TZ=America/New_York
HEARTBEAT_MAX_RUNS_PER_DAY=24

Per user — Settings → Heartbeat: Run my heartbeat, Check every (the user’s own interval, triggerConfig.intervalMinutes, 5–1440 minutes, instead of heartbeat.intervalMinutes) and Watch my inbox. The tab calls GET/PUT /api/heartbeat/me, which use ensureHeartbeatHook / disableHeartbeatHook and merge the rest into the hook’s trigger config. The global switch still gates whether any user’s hook actually runs.

SettingDefaultMeaning
heartbeat.enabledfalseMaster switch.
heartbeat.intervalMinutes60Minutes between runs per user (5–1440).
heartbeat.quietHoursStart / End22 / 7No runs in [start, end); equal = disabled.
heartbeat.quietHoursTimezoneUTCIANA tz for quiet hours + the daily-cap day boundary.
heartbeat.maxRunsPerDay24Hard per-user daily cap (1–288).
heartbeat.probeGithubtrueWake for your open PRs with failing checks (HEARTBEAT_PROBE_GITHUB).
heartbeat.probeCalendartrueWake for calendar events about to start (HEARTBEAT_PROBE_CALENDAR).
heartbeat.calendarLookaheadMinutes60How far ahead the calendar probe looks (5–1440; HEARTBEAT_CALENDAR_LOOKAHEAD_MINUTES).
heartbeat.probeEmailtrueAllow the inbox watch for users who turned it on (HEARTBEAT_PROBE_EMAIL).

Unattended agent actions remain subject to permissions: ASK blocks rather than auto-approving. Standing instructions are prompt guidance, not a grant of access.

  • Cheap-first gate — quiet hours, daily cap, and quota are checked before the DB probe; the probe is checked before any LLM turn.
  • No duplicate fires — next_run_at is advanced before the (possibly long) turn starts.
  • Auditability — heartbeat runs carry origin='heartbeat' in the ambient RunContext (and channel='heartbeat' in trajectories).
  • Kill switch — disable the hook (disableHeartbeatHook) or flip heartbeat.enabled off.
  • A first-class goals object — standing instructions are a pinned note for now (deliberate; schema can follow usage).

Edit this page in the repository.