AJERISDocs

Scheduled tasks

The heartbeat scheduler. How Ajeris runs recurring jobs, daily briefings, and proactive checks.

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

PatternHermes implementationOur adaptation
Job storageJSON file (~/.hermes/cron/jobs.json)DB table (scheduled_tasks), we're cloud-hosted, not local
Schedule typesonce / interval / cronSame three types. Use croniter pattern for cron expressions
Duration parser30m, 2h, 1d → minutesSame. Clean, human-readable
Tick mechanismBackground thread, every 60snode-cron or setInterval in the agent process
Security scanningRegex + invisible unicode on cron promptsSame. Prevent prompt injection in scheduled task text
Output storageoutput/{job_id}/{timestamp}.mdDB row in scheduled_task_runs table
Delivery targetslocal / origin / platform:chat_idSMS (push_to_phone) / voice (if Alexa active)
Silent marker[SILENT] suppresses deliverySame. Agent outputs nothing → no SMS sent
Cronjob MCP toolSingle cronjob tool with actionsSame pattern. scheduled_task tool with create/list/remove/pause/trigger

What we take from OpenClaw

PatternOpenClaw implementationOur adaptation
Empty checkisHeartbeatContentEffectivelyEmpty()Skip execution when no tasks are due (save API cost)
Session isolationHeartbeat runs use :heartbeat session keySeparate conversation context for scheduled runs (don't pollute user chat history)
Phase schedulingSHA-256 deterministic offsetNot needed (single user per container) but useful at scale
HEARTBEAT_OK ackAgent says "nothing to report" → suppressSame. 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-cron which 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 seconds

Each tick:

  1. Query scheduled_tasks WHERE enabled = true AND next_run_at <= now()
  2. 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. Update lastRunAt, lastRunStatus, lastRunResult g. Compute nextRunAt based on schedule kind h. For "once" tasks → set enabled = false after 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

#TaskScope
1Schema + migration for scheduled_tasks~10 min
2Schedule parser (duration, cron, natural time)~30 min
3scheduled_task MCP tool (create/list/remove/pause/resume/trigger)~45 min
4Scheduler tick function (query due tasks, run agent, deliver)~45 min
5Wire tick into agent startup (setInterval)~10 min
6Security scanner for task prompts~15 min
7Tests (parser, tool, tick, security)~30 min
8E2E: "remind me at X" → SMS arrives~15 min
9Commit~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