Skip to content
▚ evestack docs

Proactive agents

An agent that wakes up on its own and messages you first — with budgets, approvals and an audit trail.

An agent that only answers when spoken to is a chatbot. An agent that wakes up, checks on things, and messages you when something needs you is a different product — and it is the one that made personal AI assistants the most-installed category of 2026.

It is also the one that made them the most notorious. That category shipped with agents holding shell access and long-lived credentials, no spend limit, no record of what was approved, and in one widely-reported case a skills marketplace where the most-downloaded package was an infostealer. The capability was never the problem. The absence of everything around it was.

evestack ships the same capability with the governance attached. This page is the recipe.

The five pieces

Everything here already exists in the default template. Turning the heartbeat on is the only step that is off by default.

What it doesWhere
Heartbeatwakes the agent on a cron, and stays quiet unless there is newsagent/schedules/heartbeat.ts
A channelwhere it reaches you — Telegram is the fastest to finishagent/channels/
Memoryso it remembers what it noticed last time@evestack/memory
Budget capsa hard ceiling on what unattended work can cost@evestack/budget
A gated toolanything destructive parks for a human, and the decision is recordedapproval: always()

Turn it on

# 1. a channel it can reach you on (Telegram is a two-minute setup — see /docs/channels/telegram)
TELEGRAM_BOT_TOKEN=123456789:AAH...
TELEGRAM_WEBHOOK_SECRET_TOKEN=...

# 2. the heartbeat itself
EVESTACK_HEARTBEAT_CHANNEL=telegram
EVESTACK_HEARTBEAT_TARGET={"chatId":123456789}
EVESTACK_HEARTBEAT_CRON=0 * * * *

Then write what it should check. HEARTBEAT.md sits at the root of the project and is read at every fire, so editing it takes effect on the next wake-up — no restart, no redeploy.

## Checks

- Look through my recent memories with `recall`. If any two contradict each other, tell me
  which and ask which one is right.
- If any session in the last 24 hours ended in an error, summarise what failed.
- Anything I asked you to follow up on that has gone quiet for more than two days.

eve dev does not fire schedules on a timer

Worth knowing before you conclude the heartbeat is broken, because everything looks right: the schedule compiles, the agent boots clean, and nothing ever happens.

Under eve dev, schedules are registered but not driven by a clock. They fire when you ask:

curl -X POST http://localhost:2000/eve/v1/dev/schedules/heartbeat
# {"scheduleId":"heartbeat","sessionIds":["wrun_…"]}

The id is the filename under agent/schedules/. A 404 lists the ids that do exist, which is the fastest way to check eve found your file at all.

The clock arrives with a built server — eve build && eve start runs Nitro's schedule runner, and that is what fires the cron in production. So the loop to expect is: develop against the dev route, deploy for the timer.

This is eve's behaviour, not evestack's; tracked() records a dispatched fire exactly as it records a scheduled one, so the Schedules page fills in either way.

Why it does not become spam

This is the part that decides whether the feature survives contact with real use.

The agent is told to reply with exactly HEARTBEAT_OK when nothing needs you. An hourly heartbeat that always sends something is an hourly notification, and you will mute it within a day — at which point you have a worse product than one that never spoke.

Two things this page used to promise that the code does not do. Both were found by auditing the feature rather than reading it, and both are written up in the note at the top of agent/schedules/heartbeat.ts.

The token is not dropped. Nothing filters it. The handler hands the turn to eve with receive(), and eve posts the reply itself, so evestack never sees the text and has nowhere to filter it from. A quiet hour therefore delivers the literal string HEARTBEAT_OK to your channel.

There used to be an exported isWorthDelivering(reply) predicate here, called from nowhere. It has been deleted rather than shipped in a template people read and edit — a function named after what it would do, wired to nothing, reads as a working feature whatever its comment says. The one rule it encoded is recorded in agent/schedules/heartbeat.ts for whoever wires it, along with the two places it could actually go.

Wake-ups are not isolated sessions. This page said each one "runs in an isolated session with a light context". The isolatedSession that was credited for it does not exist anywhere in evestack or in eve — so a wake-up costs whatever the session it lands in costs, and the few-thousand-tokens figure describes an intended design, not this code.

What keeps it safe

It cannot quietly spend your money. @evestack/budget caps at $2 per session and $10 per principal per day by default, and unattended work is exactly where an uncapped agent hurts — nobody is watching the turn that loops.

It cannot quietly do damage. Anything behind approval: always() parks and waits. The template ships forget that way as the worked example. A heartbeat that hits an approval will sit there until a human answers, which is why HEARTBEAT.md tells the agent to describe what it would do rather than request approval it cannot get at 3am.

Every decision is attributable. The Approvals page records who approved what, when, and how their identity was established. Set EVESTACK_REQUIRE_APPROVER=1 to refuse decisions that cannot be attributed to a person at all.

Every fire is on the record. The Schedules page shows each wake-up, what it cost, what failed, and lets you pause one without a redeploy. Self-hosted eve runs schedules in-process and keeps no history, so without this a 3am job that has been failing for a week looks identical to one that has been fine.

Skills are scanned before they load. eve advertises every skill in agent/skills/ to the model and hands it a load_skill tool, so a skill can put instructions into a live turn before you see them. The Skills page scans each one for injection, credential and exfiltration patterns — and says plainly that a clean verdict is not proof of safety.

Catch-up, and when you do not want it

If the machine was asleep at 09:00, should the 09:00 heartbeat run at 09:40?

For a digest, yes — you still want it. For anything that acts on the world, no. So catch-up is opt-in per schedule, capped, and bounded by a window: the heartbeat replays at most 3 missed fires from the last 6 hours, so a laptop shut for a week does not wake up and replay a week.

tracked("heartbeat", CRON, handler, {
  catchUp: true,
  catchUpLimit: 3,
  catchUpWindowMs: 6 * 60 * 60 * 1000,
});

Replayed fires are labelled as replays in the history, because a replay is not the same event as a live fire.

Honest limits

  • The heartbeat needs the agent running. It is a cron inside your process, not a hosted scheduler. If the box is off, nothing fires — catch-up is what softens that, not a fix for it.
  • Channels need a public HTTPS URL. None of Slack, Discord or Telegram has a polling mode in eve, so a laptop needs a tunnel. See Telegram, which spells out the tunnel step; Slack and Discord are the same shape.
  • A denied approval used to kill the session. That was a real eve bug, reported upstream and first worked around in the template's model middleware; @ai-sdk/openai v4 handles the denied output type natively, which is what the template pins. It matters more here than anywhere: an unattended agent hitting a denial at 3am should still be alive in the morning.