Heartbeat Scheduler, Proactive Agent Architecture
Status: DESIGN COMPLETE, ready for implementation.
Author: Ajeris team
Date: 2026-04-13
Research basis: OpenClaw src/infra/heartbeat-runner.ts +
src/auto-reply/heartbeat.ts and Hermes Agent cron/scheduler.py +
cron/jobs.py + tools/cronjob_tools.py, both read and verified
in source code before writing this doc.
1. What this is
A proactive scheduling system that lets the Ajeris agent run tasks on its own, without the user sending a message first. Today, the agent is purely reactive: it only runs when a user texts or speaks. After this, the agent can also:
- "Remind me at 7pm to leave for the airport" → agent pushes SMS
- "Check my email every morning at 8am" → agent summarizes inbox
- "Watch Sentry for errors and text me if anything critical" → agent monitors and alerts
- "Every Monday morning, give me a briefing of my week" → agent composes and delivers
This is the feature that takes Ajeris from "personal assistant you talk to" to "personal assistant that works for you in the background."
2. Design decisions (informed by competitors)
What we take from Hermes
| Pattern | Hermes implementation | Our adaptation |
|---|---|---|
| Job storage | JSON file (~/.hermes/cron/jobs.json) | DB table (scheduled_tasks), we're cloud-hosted, not local |
| Schedule types | once / interval / cron | Same three types. Use croniter pattern for cron expressions |
| Duration parser | 30m, 2h, 1d → minutes | Same. Clean, human-readable |
| Tick mechanism | Background thread, every 60s | node-cron or setInterval in the agent process |
| Security scanning | Regex + invisible unicode on cron prompts | Same. Prevent prompt injection in scheduled task text |
| Output storage | output/{job_id}/{timestamp}.md | DB row in scheduled_task_runs table |
| Delivery targets | local / origin / platform:chat_id | SMS (push_to_phone) / voice (if Alexa active) |
| Silent marker | [SILENT] suppresses delivery | Same. Agent outputs nothing → no SMS sent |
| Cronjob MCP tool | Single cronjob tool with actions | Same pattern. scheduled_task tool with create/list/remove/pause/trigger |
What we take from OpenClaw
| Pattern | OpenClaw implementation | Our adaptation |
|---|---|---|
| Empty check | isHeartbeatContentEffectivelyEmpty() | Skip execution when no tasks are due (save API cost) |
| Session isolation | Heartbeat runs use :heartbeat session key | Separate conversation context for scheduled runs (don't pollute user chat history) |
| Phase scheduling | SHA-256 deterministic offset | Not needed (single user per container) but useful at scale |
| HEARTBEAT_OK ack | Agent says "nothing to report" → suppress | Same. Don't buzz the user's phone with "all clear" on every check |
What we DON'T take
- File-based storage (both), we use Postgres, not filesystem
- HEARTBEAT.md format (OpenClaw), we use a DB table + MCP tool, not a Markdown file
- Multi-agent heartbeat (OpenClaw), we have one agent per user
- croniter dependency (Hermes), we'll use
node-cronwhich is already a common Node.js pattern - File-based locking (Hermes), not needed, single process per user
3. Schema
model ScheduledTask {
id String @id @default(uuid()) @db.Uuid
userId String @map("user_id") @db.Uuid
name String
prompt String // What the agent should do
schedule String // "every 30m", "0 9 * * *", "2026-04-15T14:00:00"
scheduleKind String @map("schedule_kind") // "interval" | "cron" | "once"
intervalMinutes Int? @map("interval_minutes") // for interval kind
cronExpr String? @map("cron_expr") // for cron kind
runAt DateTime? @map("run_at") @db.Timestamptz // for once kind
enabled Boolean @default(true)
deliverVia String @default("sms") @map("deliver_via") // "sms" | "voice" | "silent"
lastRunAt DateTime? @map("last_run_at") @db.Timestamptz
lastRunStatus String? @map("last_run_status") // "ok" | "error" | "silent"
lastRunResult String? @map("last_run_result") // summary text
nextRunAt DateTime? @map("next_run_at") @db.Timestamptz
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, enabled])
@@index([enabled, nextRunAt])
@@map("scheduled_tasks")
}4. The scheduled_task MCP tool
Single tool with actions (Hermes pattern, avoids schema bloat):
scheduled_task({
action: "create" | "list" | "remove" | "pause" | "resume" | "trigger",
// For create:
name?: string, // "Morning briefing", "Airport reminder"
prompt?: string, // "Summarize my unread email"
schedule?: string, // "every 30m", "0 9 * * 1-5", "7pm today"
deliver?: string, // "sms" (default) | "silent"
// For remove/pause/resume/trigger:
taskId?: string,
})User says: "Remind me at 7pm to leave for the airport"
Agent calls: scheduled_task({ action: "create", name: "Airport reminder", prompt: "Remind the user to leave for the airport now", schedule: "7pm today", deliver: "sms" })
Result: Task created, will fire at 7pm via push_to_phone.
User says: "Check my email every morning at 8am"
Agent calls: scheduled_task({ action: "create", name: "Morning email", prompt: "Summarize unread emails and text the user", schedule: "0 8 * * *", deliver: "sms" })
5. Scheduler tick (runs in the agent process)
// In agent/index.ts, after server starts:
setInterval(async () => {
await tickScheduler({
prisma,
userId,
userContext,
mcpServerEntrypoint,
anthropicApiKey,
mcpEnv,
log,
});
}, 60_000); // Every 60 secondsEach tick:
- Query
scheduled_tasks WHERE enabled = true AND next_run_at <= now() - For each due task:
a. Run
runAgent()with the task's prompt as the user message b. Channel = 'sms' (for push_to_phone) or 'silent' (log only) c. Use frozen session (same system prompt, no cache miss) d. If agent's reply starts with[SILENT]or is empty → don't deliver e. Otherwise → call push_to_phone to SMS the result to user f. UpdatelastRunAt,lastRunStatus,lastRunResultg. ComputenextRunAtbased on schedule kind h. For "once" tasks → setenabled = falseafter running
6. Schedule parsing
parseSchedule("every 30m") → { kind: "interval", minutes: 30 }
parseSchedule("every 2h") → { kind: "interval", minutes: 120 }
parseSchedule("0 9 * * 1-5") → { kind: "cron", expr: "0 9 * * 1-5" }
parseSchedule("7pm today") → { kind: "once", runAt: "2026-04-13T19:00:00-04:00" }
parseSchedule("tomorrow at 3pm") → { kind: "once", runAt: "2026-04-14T15:00:00-04:00" }
parseSchedule("in 30 minutes") → { kind: "once", runAt: now + 30m }Natural language time parsing via a small helper that handles common patterns. Cron expressions validated via regex (5-field check). For V1, natural language is limited to patterns we can regex-match. V2 could use the LLM itself to parse arbitrary time expressions.
7. Delivery
When a scheduled task fires:
SMS delivery (default): Agent runs with the task prompt, composes
a reply, then the scheduler calls push_to_phone to SMS the result.
The user gets a text like:
Morning briefing (8:00 AM) You have 3 unread emails, one from Alice about the Q2 deck, one from your bank, and a newsletter. Today's calendar: standup at 9am, lunch with Dave at noon. Want me to dig into any of these?
Silent delivery: Agent runs but output is only logged to DB (for monitoring tasks that should only alert on anomalies). The agent can CHOOSE to push_to_phone if it finds something worth reporting.
Voice delivery (future): If the user's Alexa is active, speak the result. Not in V1, push-to-voice requires Alexa proactive events API which has a separate authorization model.
8. Security
Prompt injection scanning before storing scheduled task prompts
(pattern from Hermes _scan_cron_prompt):
const THREAT_PATTERNS = [
/ignore\s+(?:\w+\s+)*(?:previous|all|above)\s+.*instructions/i,
/do\s+not\s+tell\s+the\s+user/i,
/system\s+prompt\s+override/i,
/disregard\s+(your|all|any)\s+(instructions|rules)/i,
// Exfiltration
/curl\s+.*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD)/i,
/cat\s+.*\.(env|credentials|netrc|pgpass)/i,
/authorized_keys/i,
/rm\s+-rf\s+\//i,
];Invisible unicode detection (zero-width joiners, bidi overrides, etc.) also checked, same as Hermes.
9. Implementation plan
| # | Task | Scope |
|---|---|---|
| 1 | Schema + migration for scheduled_tasks | ~10 min |
| 2 | Schedule parser (duration, cron, natural time) | ~30 min |
| 3 | scheduled_task MCP tool (create/list/remove/pause/resume/trigger) | ~45 min |
| 4 | Scheduler tick function (query due tasks, run agent, deliver) | ~45 min |
| 5 | Wire tick into agent startup (setInterval) | ~10 min |
| 6 | Security scanner for task prompts | ~15 min |
| 7 | Tests (parser, tool, tick, security) | ~30 min |
| 8 | E2E: "remind me at X" → SMS arrives | ~15 min |
| 9 | Commit | ~5 min |
Total: ~3.5 hours
10. Example user flows
Flow 1: Simple reminder
User (voice): "Hey my agent, remind me at 7pm to leave for the airport"
Agent calls: scheduled_task(create, "Airport reminder",
"Remind the user: time to leave for the airport!", "7pm today", "sms")
Agent says: "Got it, I'll text you at 7pm."
...
At 7pm:
Scheduler tick → task is due → runAgent("Remind the user: time to
leave for the airport!") → agent composes: "Hey! Time to head out
for the airport. Safe travels!" → push_to_phone → user gets SMS
Flow 2: Recurring monitoring
User (SMS): "check sentry for errors every 2 hours and text me if
anything is critical"
Agent calls: scheduled_task(create, "Sentry monitor",
"Check Sentry for critical errors. If none, reply [SILENT]. If
there are critical errors, summarize them for the user.",
"every 2h", "sms")
Agent replies: "On it, I'll check Sentry every 2 hours and only
bug you if something's actually wrong."
...
Every 2 hours:
Tick → runAgent → calls sentry_errors (from arvexi-ops custom MCP)
→ no critical errors → agent replies "[SILENT]" → suppressed
...
Eventually: critical error found → agent replies with summary →
push_to_phone → user gets SMS alert
Flow 3: Morning briefing
User (voice): "Give me a briefing every weekday at 8am, email,
calendar, and any Slack messages I missed"
Agent calls: scheduled_task(create, "Weekday briefing",
"Give the user their morning briefing: summarize unread email,
today's calendar, and any unread Slack messages.",
"0 8 * * 1-5", "sms")
Agent says: "Weekday briefings at 8am, you'll get email, calendar,
and Slack every morning."
...
Monday 8am:
Tick → runAgent → calls gmail_summarize_unread + calendar_today +
slack_list_unread → composes briefing → push_to_phone → SMS