A real clock, in the main process: cron expressions, one-shots, and intervals that keep time even with the window closed — plus silent heartbeat checks and a localhost webhook to poke her from outside.
Before 1.7.0, scheduled tasks ran on a timer inside the renderer — close the window and the clock died with it. Now the scheduler is a main-process service: it loads its jobs from a plain JSON file in the app's data folder, drives a 30-second tick, and hands due jobs to the app's normal unattended run path. As long as the app process is alive — window closed, minimized, whatever — schedules fire on time. If the app isn't running at all, nothing fires; the catch-up policy below handles what happens when it comes back.
Jobs persist atomically (written to a temp file and renamed, so a crash can never leave a half-written schedule file), and there is a cap of 100 live jobs. Spent one-shots are kept as disabled receipts rather than deleted, and they don't count against the cap.
A schedule is one string, in one of three forms:
minute hour day-of-month month day-of-week, numeric only. 30 8 * * 1-5 is weekdays at 8:30am, local time. Supported per field: *, */n, a, a-b, a-b/n, and comma lists of those. Not supported — and rejected with an honest error instead of silently misparsing: month and day names (JAN, MON), @macros (@daily, @reboot), L/W/#, seconds fields, and timezones. Everything runs on the machine's local clock.The standard cron day rule applies: when both day-of-month and day-of-week are restricted, a date matches if either does. An expression that never matches a real date within five years — say, 0 0 31 2 * — is rejected at save time, not silently scheduled forever.
The machine sleeps, the app quits, three hours pass over an every:30 job. On wake, that job fires once, not six times — the next run is always recomputed from now after a fire, never from the missed slots. This is the difference between "catch me up" and a queue of stale replays hammering you the moment the lid opens.
A job can also opt out of catch-up entirely with catchUp: false, for work where a stale fire is worthless — a missed run is simply skipped and the job reschedules. Heartbeats (below) use this: a health check from two hours ago tells you nothing. The lateness threshold is 90 seconds — three ticks — so ordinary jitter never trips it and real sleep or quit gaps always do.
Every scheduled run — cron, one-shot, interval, heartbeat, webhook-triggered — carries a safety prefix ahead of its prompt, verbatim and non-negotiable: the run is told it is executing unattended, that you are not there to approve anything, and that it must not send messages or emails, make purchases, or take any irreversible action. Gather, summarize, draft, prepare — then report.
This is the same rule the older schedule_task runs carried, and it applies identically to jobs fired through the webhook. If you want Mori to actually send the weekly summary email, the scheduled run drafts it and the send happens when you're present to say yes.
A heartbeat is a cron preset, not a new subsystem: an every:30 job with catchUp: false and a quiet contract. The job reads a checklist file — heartbeat.md in the app's data folder (~/Library/Application Support/Studio Mori/heartbeat.md on macOS, %APPDATA%\Studio Mori\heartbeat.md on Windows) — and runs each check with read-only tools. You write the checklist in plain language: is the dev server up, did the overnight build finish, is disk space okay.
The contract: if the file doesn't exist, or every check passes, the run replies with exactly HEARTBEAT_OK — and that reply triggers total silence. No notification, no saved transcript, nothing. Only when something genuinely needs your attention do you hear about it, as one short paragraph describing the problem. An all-green heartbeat leaves no trace beyond a single audit line.
# heartbeat.md
- is anything listening on port 4181?
- does ~/Desktop/mori/firstapp build output from last
night contain the word "error"?
- is free disk space above 20GB?You don't write cron strings yourself unless you want to. Tell Mori "check my dev server every half hour" or "every weekday at 8:30 summarize my kanban board" and she calls the schedule_cron tool with the prompt and schedule filled in — the tool takes a 5-field cron expression, at:<epoch-ms>, or every:<minutes>, plus a heartbeat flag for quiet jobs. Invalid schedules come back with the parser's specific complaint ("month value out of range", "@macros are not supported"), so a bad request fails loudly at creation, not silently at 3am.
Jobs created under the old renderer scheduler are migrated to the main-process service once, automatically, with nothing firing twice and nothing dropped.
The MCP server's listener (the same one that exposes Mori's tools to other agents — see MCP, both directions) gains one endpoint: POST /hooks/wake on 127.0.0.1. It is off by default, separately from the MCP server itself, and flipping it on requires editing hooksEnabled in the server's config — no restart needed, the flag is re-read per request.
A call must carry the server's bearer token and either a jobId — fire that existing cron job now — or a prompt, which becomes an immediate one-shot run. Either way the run goes through the exact same unattended fire path as any schedule, safety prefix included, so a webhook poke can't do anything a scheduled run couldn't. Guardrails: the listener binds to localhost only (nothing off-machine can reach it), requests are rate-limited to 10 per minute, and a bad token gets a flat 403.
What this is for: a git post-push hook that has Mori review the diff, a CI script that pokes her when a build finishes, a Shortcuts automation on your Mac. What it is not: a public API. There are no tunnels and no cloud relay — if you want remote triggering, that's what the bots and Mori Link are for.
curl -X POST http://127.0.0.1:4519/hooks/wake \
-H "Authorization: Bearer <your-mcp-token>" \
-H "Content-Type: application/json" \
-d '{"prompt": "the deploy just finished — check the\n site loads and summarize anything broken"}'MON, @daily, L, W, # are rejected with an error that says exactly what is supported.