Docs  Automation & schedules

Mori docs

Automation & schedules

Automations that run on a schedule, on demand, on a webhook, on a GitHub event or on a message, a real clock that keeps time with the window closed, and quiet checks that speak only when something changed.


The Automations page

Automations, under More, is where standing work lives. An automation is a prompt with up to twenty triggers:

  • Schedule, in the time zone you pick, at most every five minutes.
  • On demand, with Run now.
  • Incoming webhook, on a signed address.
  • GitHub events: a pull request opened, updated or closed, a comment, a label, a check.
  • Channel message: a message on one of your bots' channels.

Each run is kept in a history with what it cost, and templates start the common ones. /blueprint sets up a routine from the chat, such as a morning brief.

The clock lives in the main process

The scheduler runs in the app's main process: it loads its jobs from a plain file in the app's data folder, ticks every 30 seconds, and hands due jobs to the app's normal unattended run. As long as the app is running, with the window closed or minimised, schedules fire on time. If the app is not running, nothing fires; the catch-up rule below covers what happens when it comes back.

Jobs are written atomically, so a crash never leaves a half-written schedule, and there is a cap of 100 live jobs. Spent one-shots are kept as disabled records and do not count against the cap.

Caps, quiet hours and retries

  • A job can have a cap on runs a day, and quiet hours when it does not fire.
  • When the model cannot be reached, a run is tried again after 5, 15 and 30 minutes.
  • A job can remember its last report and send only what changed (only_changes), and it reports back to the thread that created it.
  • /loop checks again every few minutes inside a thread until a condition holds, and /heartbeat keeps a standing look with one quiet line when nothing changed.

Three ways to say when, in a raw schedule

A schedule is one string, in one of three forms:

  • A 5-field cron expression: 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 time zones. A raw schedule runs on the machine's local clock; schedules made on the Automations page take a time zone.
  • at:<epoch-ms>: a one-shot. Fires once, then the job flips to disabled (kept as a record). A timestamp in the past is allowed: an "at now" one-shot fires on the next tick.
  • every:<minutes>: a simple interval, whole minutes, minimum 1.

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.

Missed runs: catch up once, never replay

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.

What unattended runs are allowed to 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.

The same rule applies to jobs fired through the webhook, and a webhook's turn gets only a short list of safe tools. Unattended runs keep every rail on and never drive your screen. 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.

When a job should write files, allowWritesUnder names the folders it may write in, and only those.

Heartbeats: silence is the success signal

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?

Asking for a schedule in chat

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.

Waking Mori from outside: the localhost webhook

The MCP server's listener (the same one that exposes Mori's tools to other agents, see MCP, both directions) has 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"}'

Honest limits

  • The app must be running. The main process survives window close, but not quit. A machine that was off all weekend fires each missed job once on launch (unless the job opted out).
  • Raw schedules use local time. A schedule_cron string has no time zone field; the Automations page's schedules do.
  • Minute resolution. The tick is 30 seconds and cron has no seconds field; don't schedule anything that needs sub-minute precision.
  • Numeric cron only. MON, @daily, L, W, # are rejected with an error that says exactly what is supported.
  • Unattended means restricted. No purchases and no irreversible actions on any path. Writes go only to the folders you named.