Skip to content

Reminders and heartbeats ​

Lemon can reach out to you instead of waiting to be asked. Scheduled proactive features are ordinary cron jobs run by the agent:

  • Reminders — one-off or recurring messages you set from Telegram with /remind, or by asking the agent.
  • Heartbeats — a recurring check-in where the agent works through a checklist and only messages you when something needs attention.

Scheduled work needs Lemon running. On restart, an enabled job catches up once if its stored slot is within half the gap to its next occurrence, clamped to 2 minutes–2 hours. Catch-up runs have catch_up: true in history. Older slots are audited as skipped; expired one-off reminders are retired, not moved to next year. Paused jobs stay paused. Set [automation] catch_up_missed = false to disable catch-up. To stop every job at once, see Pausing all cron jobs.

If startup cannot read the global-pause marker or persist a required skipped slot/expired one-off retirement, the scheduler stays alive in restoration-pending state. All scheduled dispatch, wake requests and retry submissions wait while it retries durable restoration, starting after 250 ms and backing off to at most 5 seconds between attempts. Other automation services and explicit manual runs (run_now, or lemon cron run <id|name>) remain available while restoration is pending. Status exposes this independent hold as restorationPending; it does not mean the durable global pause (paused) is set. An unreadable marker is not a confirmed pause: after storage recovers, normal startup skipping completes before dispatch is allowed. A confirmed durable pause instead preserves stored schedules until explicit resume.

A paired chat is needed only for chat delivery. Run history retains the latest 100 terminal runs per job, cleaned up hourly; active attempts and pending retries are never pruned. Configure automation.run_retention_per_job to change that limit.


Where results go ​

When a scheduled job finishes, Lemon sends the result to one chat, chosen in this order:

JobDelivered to
deliver_to = "none"Nowhere. The run is still recorded in run history.
deliver_to = "telegram:<chat_id>[:<topic_id>]" or "discord:<channel_id>[:<thread_id>]"That chat, topic, or thread.
Created from a Telegram or Discord conversationThe chat (and topic/thread) it was created in.
Created anywhere else (TUI, browser, CLI)Your paired owner chat: the first gateway.telegram.allowed_chat_ids entry when Telegram is enabled, otherwise gateway.discord.default_channel_id when Discord is enabled.
No owner chat configuredNowhere.

Successful results contain the job name and the agent's reply. Failures use the job's delivery route first, falling back to the paired owner chat if there is no job route (including deliverTo: "none" and command jobs without a target). This fallback is only for failure/recovery alerts; it does not change ordinary command-result delivery.

Failure alerts and recovery ​

Lemon persists each job's failure streak and last terminal status. Every failed or timed-out terminal run increments the streak; success resets it to zero. The cron tool and cron.list return failureStreak and lastStatus; list with failing: true to see jobs with a nonzero streak. lemon cron status and cron.status include the failingJobs count.

The first failure in an incident alerts immediately. Repeated failures with the same normalized error class are quiet for six hours; a new error class alerts immediately. Incident cooldowns survive restart. Configure the non-negative [cron] failure_alert_cooldown_ms setting (default 21600000) to change the interval; 0 alerts on every failure. Alerts contain a bounded, credential- redacted job name, streak, safe error label, and an inspection command:

bash
lemon cron runs --job <id>

A previously alerted job sends one recovery notice on its next successful, non-suppressed run, instead of a second ordinary result message. Exact [SILENT] and healthy heartbeat HEARTBEAT_OK successes never notify, even on recovery; failures always enter the alert policy.

If no route exists, or alert admission fails, Lemon durably records the latest undelivered alert per job (up to 200 jobs). lemon doctor warns about unread failing jobs; redacted cron diagnostics include their streaks and last statuses. Recovery or successful alert admission resolves that local notice. Run history remains available whether or not a chat is paired.

Most people only need the owner chat. Pair Telegram once and every reminder, heartbeat, and brief reaches your phone:

bash
lemon gateway setup telegram
lemon restart

See Setup › Optional Telegram or Discord for pairing details.


Pausing all cron jobs ​

One switch holds back every cron job — reminders, heartbeats, scheduled coding jobs, and blueprint jobs — across all profiles, without editing the jobs themselves:

bash
lemon cron pause --all     # hold back every job
lemon cron status          # prints "All cron jobs are PAUSED" while it is on
lemon cron resume --all    # release them

You can also ask the agent to pause or resume all cron jobs (the cron tool's pause_all and resume_all actions). Operators using the control plane send cron.pause or cron.resume with {"all": true} (admin scope).

  • The pause persists. It is stored with the rest of cron state, so lemon restart, a crash, or a reboot does not lift it. While paused, a restart leaves each job's stored schedule untouched and catches nothing up.
  • Missed runs are skipped on resume, never caught up. Every scheduled run that came due during the pause, and every failure retry that came due, is dropped and recorded in the audit log (missed_slot_skipped/retry_skipped with reason global_pause). Each job then continues at its next scheduled time after the resume. A one-off reminder whose time passed during the pause is retired, like any expired one-off. This applies whether or not catch_up_missed is set.
  • What is held back: scheduled runs, pending retries, and wake triggers. A scheduled or retry run that was claimed a moment before the pause but has not begun submission is invalidated before pause succeeds, even if its jitter timer fires after a subsequent resume. A worker still preparing submission must pass the same pause check at the final submission boundary.
  • What is not affected: runs already in progress finish normally (use cron → abort to stop one), and lemon cron run <id|name> still starts a job on demand, as it does for an individually paused job. Each job's own pause is separate: pause --all does not change it and resume --all does not re-enable an individually paused job. Goal loops, kanban dispatch, and skill curation have their own controls and are not paused.
  • It is visible. lemon cron status prints the paused state and any pending restoration hold. The JSON projection, cron.status, and the cron tool's status action carry paused, pausedAtMs, and restorationPending. paused describes only the durable global-pause marker; restorationPending describes the scheduler's separate startup-restoration hold. lemon doctor warns with automation.cron_paused so a forgotten pause is noticed.

Pausing again keeps the original pause time, and resuming when nothing is paused does nothing. If Lemon cannot read the stored pause state, it holds jobs back rather than guessing. Resume strictly reloads the stored jobs even if a paused restart could not read them. If it cannot enumerate jobs or retries, or persist any required skip (including retiring an expired one-off), it reports an error and keeps the global pause in force. Retry resume after storage recovers; completed skips are safe to repeat.

A successful resume can clear the durable marker (paused: false) while restorationPending remains true. Scheduled dispatch, wake triggers, and retries remain blocked until restoration succeeds; resume does not bypass that hold. Explicit manual run_now requests remain allowed. If there is no durable pause to clear, resume is still a no-op and does not clear pending restoration.


Telegram reminders ​

In a paired Telegram chat:

text
/remind 9am stand-up
/remind in 20m check oven
/remind every weekday 8:30 daily brief

Lemon replies with the schedule, the next run time, and the reminder id. When the reminder fires, the agent writes the message (doing any lookup the reminder asks for) and sends it back to the same chat or forum topic.

Supported <when> forms:

FormExamplesRepeats
Relative delayin 20m, in 2h, in 1h30m, in 3d (up to 364 days)once
Time of day9am, 9:30pm, 21:15, at 9am, noon, midnightonce — today, or tomorrow if that time has passed
Tomorrowtomorrow 9amonce
Every dayevery day 8:30, daily at 8:30daily
Weekdays / weekendsevery weekday 8:30, every weekend 10amweekly pattern
Day of weekevery monday 9amweekly
Intervalevery 30m, every 2h, every hourminutes must divide 60, hours must divide 24

A bare number is not a time: /remind 5 things is rejected. Use 5am or 05:00. Any unrecognized form replies with the usage text.

Manage reminders for the current chat:

text
/reminders          # list this chat's reminders, soonest first
/unremind <id>      # cancel one (ids come from /remind and /reminders)

/reminders and /unremind only see reminders created in the same chat or topic. One-off reminders are removed automatically after they fire.

Times are the server's local time — the clock of the machine running Lemon, including daylight-saving changes. If Lemon runs on a server in another timezone, write times in that server's zone.


Asking the agent ​

You can also ask in plain language from any interface: "remind me at 3pm to call the bank", "every Friday at 5pm summarise this week's commits". The agent uses its cron tool, which supports:

  • schedule: "in 30m" — a relative one-shot delay (m, h, or d, positive integer). The delay is pinned to an absolute instant when saved, not restarted when Lemon restarts.
  • schedule: "2027-01-15T09:30" — an ISO local timestamp in the job's timezone, optionally including seconds (2027-01-15T09:30:45). Past timestamps are rejected. Relative delays and timestamps automatically set once: true.
  • once: true — a one-shot job that runs at its next scheduled time and is then removed.
  • timezone — "UTC" by default, "local" for the server's local time, or an IANA timezone name such as "America/New_York" or "Europe/Paris". Named zones use Lemon's bundled timezone database, including daylight-saving rules. Unknown names or an unavailable database are rejected, not treated as UTC.
  • deliverTo — override the destination ("none", "telegram:<chat_id>", "discord:<channel_id>", or "auto" to restore the default).

Jobs created from the TUI or browser are delivered to your paired owner chat. Ask the agent to list, pause, or remove its scheduled jobs the same way.

Pause, resume, run, remove, and update accept an id/jobId, or a name when no id is supplied. Names match exactly, ignoring case. Unknown or duplicate names return an error listing candidates; Lemon never chooses among duplicates. For an update selected by name, put a new name in patch.name.

Quiet scheduled checks ​

Any prompt job can finish successfully with exactly [SILENT] (surrounding whitespace is ignored) to record its run with suppressed: true without sending a chat message or forwarding a session summary. For example, ask a build check to reply [SILENT] when everything is healthy and explain failures otherwise. Failures are never suppressed, and output such as Build says [SILENT] is delivered normally. This does not apply to operator shell-command output. Heartbeats also keep their existing exact HEARTBEAT_OK convention.

A morning brief is one such job. Ask the agent for something like: "every day at 7:30 local time, send me a short brief — today's scheduled jobs, anything due from memory, and open follow-ups — or reply [SILENT] if there is nothing worth saying." In Telegram, /remind every day 7:30 <the same request> creates the equivalent job for that chat.

Timezones and daylight-saving changes ​

The schedule's fields match the chosen zone's calendar and wall clock. For example, 0 9 * * * in America/New_York runs at 09:00 New York time: 13:00 UTC in summer and 14:00 UTC in winter. Next-run timestamps represent the actual UTC instant, not a wall clock mislabeled as UTC. Changing only a job's timezone also recomputes its next run.

When clocks jump forward, a missing scheduled time is skipped for that day; a 02:30 job does not run at 03:30 instead. When clocks fall back, a repeated scheduled time runs only at its first occurrence. The second occurrence is never scheduled, even if the first has already passed or Lemon starts between the two. These rules also apply to server-local schedules. Lemon does not change the server's timezone or TZ environment variable to evaluate jobs.

An ISO one-shot in a missing spring-forward time is rejected rather than moved to another time. A repeated fall-back timestamp resolves to its first instant; if that instant is already past, validation rejects it. Saved one-shot timestamps are absolute instants, so changing their timezone does not move them.

Telegram /remind and heartbeats keep using server-local time unless their underlying job is explicitly changed.

Product routine admission ​

An operator can attach a private admission callback to a profile-backed agent job. The existing cron scheduler checks it before every scheduled, manual, wake, or retry dispatch. This lets a product enforce the creator's current account status, workspace governance, bot availability, and spending policy without creating another scheduler or trusting browser credentials.

First allow the backend's exact origin when starting Lemon:

bash
export LEMON_CRON_ADMISSION_ALLOWED_ORIGINS=https://bot.example.com

Multiple origins are comma-separated. HTTPS is required outside loopback. For local development, an explicitly allowlisted http://127.0.0.1:<port> or http://[::1]:<port> is also allowed. localhost, other loopback addresses, wildcards, HTTP DNS hosts, and redirects are not accepted. The callback URL cannot contain userinfo, a query, or a fragment. The allowlist is checked again for each dispatch; removing trust prevents later launches.

The trusted backend provisions a dedicated per-routine token with the admin-only cron.admission.set RPC, then sets the job's private admission configuration using cron.add or cron.update. That token is stored encrypted on the controller and read only from its secret store, never environment or external fallback. No token, secret reference, or callback configuration is returned by job lists/events/audits or copied into run history. Public job responses expose only admissionConfigured.

The backend receives a read-only admission request bound to the actual job, profile, routine, creator, workspace, and fixed cron/router run IDs. It must authenticate the dedicated token, independently check that context, and echo the exact request context with its decision. A denied decision records a failed run with outcome denied. A timeout, disconnect, malformed/mismatched reply, missing secret, or untrusted origin records admission_unavailable. Neither outcome submits a router run or creates automatic retries. The next ordinary scheduled occurrence checks admission again. Jobs without a callback keep their existing execution behavior.

For a shared Team bot, the backend can project the scheduled creator's current connector consent and tool policy in the authenticated allow receipt. Lemon uses that creator's connector list for this invocation—not the profile owner's default credentials—and intersects tool access with the current profile floor. An explicit empty connector list attaches none. Only destination-side encrypted secret references are accepted; the controller never resolves connector tokens. Execution and private memory keys follow the normal native isolation path. Every projected invocation needs a fresh core proof bound to its fixed router run ID; borrowed proofs, missing memory scope, and shared-memory grants fail closed. The shared native factory validates/stamps the actual profile-built request before launch. This projection is never accepted from job prompts or public client metadata.

See the controller admission API for the exact integration schema.

Coding goals and scheduled coding jobs ​

Goals and prompt cron jobs execute real native coding sessions, with the same file and shell tools as interactive work. From the CLI or TUI, ask the agent to create and control them using its goal and cron tools. Use a concrete task and verification command, for example:

Set a goal in this repository: fix the addition bug in arithmetic.py, run python3 -m unittest, and summarize the change. Start a bounded two-tick loop.

A bounded goal ​

The goal tool manages the current session's durable objective:

  • set stores objective and cwd; cwd defaults to the current coding session's directory. deliver_to (auto, none, telegram:<chat>[:<thread>], or discord:<channel>[:<thread>]) picks where supervision updates go.
  • start launches a bounded loop; max_ticks (1–20, default 3) limits judge/continuation cycles. run_once starts one cycle. An optional model (provider:model) pins the coding model.
  • status shows the objective, target directory, loop state, active/last run, and last result. meta.lastResult records completion status and output.
  • pause or stop disables automatic continuation and cancels the owned run. resume makes the objective active again; use start to execute more work.

The judge checks progress in the goal's directory with read-only tools. Continuation runs use that same directory. A failed or ambiguous run does not silently start another editor: unknown completion remains under reconciliation until the original run is terminal. Loops do not enable automatic restart by default.

A scheduled coding task ​

Ask for a cron job with prompt, schedule, and an optional cwd and model. For example:

Create a disabled cron job in this repository to fix arithmetic.py and run its Python tests. Use gpt-6.1-sol and no chat delivery, then run it once now.

The tool's add action inherits the current coding directory unless cwd is specified. The directory is persisted, so daemon cwd and future interactive sessions do not redirect the job to another repository. You can update it explicitly. Prompt jobs run in separate conversation sub-sessions; their completed summaries and router run IDs remain in cron → runs history.

  • run executes a job immediately, including a disabled job.
  • pause disables future scheduled launches; resume enables them again.
  • abort takes the cron run ID, not the job ID, and stops an active run. Pausing a schedule alone does not cancel work already in progress.
  • runs reports progress, output, and the underlying router run ID.

maxRetries applies to definite scheduled-run failures with bounded backoff. Manual runs are single-shot. A timeout or lost acknowledgement is not proof of failure: Lemon retains the fixed run ID and reconciles completion rather than launching a duplicate coding session.

These jobs edit the target directory directly; they do not create or merge Git worktrees automatically. Point cwd at a dedicated worktree when other agents or people are editing the same repository.


Heartbeats ​

A heartbeat is a recurring job for an agent's main session: the agent runs the prompt you give it and reports back only when something needs attention. Keep the checklist in HEARTBEAT.md in the assistant workspace (~/.lemon/agent/workspace/HEARTBEAT.md), one line per thing worth checking — failing CI, unanswered messages, tomorrow's calendar — and point the prompt at it.

The reply rule (the default HEARTBEAT.md template spells it out for the agent):

  • If nothing needs your attention, the agent replies exactly HEARTBEAT_OK. That run is marked suppressed, kept in run history, and not sent.
  • Any other reply, and any failed run, is delivered like any other job result.

Set a heartbeat from the TUI (/hb is an alias):

text
/heartbeat every 30m check HEARTBEAT.md and tell me only what needs attention
/heartbeat status
/heartbeat pause
/heartbeat resume
/heartbeat clear

/heartbeat with no argument shows status; stop and off are aliases of clear. Setting a heartbeat again updates the agent's existing heartbeat job. It runs in the server's local time and is delivered to your paired owner chat.

Intervals must be cron-exact: minutes that divide 60 (1m … 30m), hours that divide 24 (1h, 2h, 3h, 4h, 6h, 8h, 12h), or 1d (daily at 09:00). Values such as 90m or 5h are rejected, and the minimum is 1m.


See Also ​

Released under the MIT License.