Schedules

~4 min read

Schedules

Time-driven recurring runs. Lives in <vault>/.lettuce/schedules.yml.

Like Vault automation/01 Triggers, a schedule does not run an agent by itself. When a rule fires it splices a > [!ai] <prompt> directive into a target note (or runs a maintenance action:). The actual work is done by whatever MCP agent is connected — it picks the directive up via pending_directives.

The file

A flat YAML list of rules:

- daily: "07:00"
  target: "Inbox/Triage.md"
  prompt: |
    Triage everything in Inbox/ created in the last 24h.
    Move to a real home or archive. Propose, don't apply.

- weekly: "Fri 17:00"
  target: "Reviews/{{week}}.md"
  prompt: |
    Build the weekly review note for this week.

Schema

Each rule is a map with these keys:

Key Type Required Notes
daily "HH:MM" one of daily/weekly fires every day at this time
weekly "<Day> HH:MM" one of daily/weekly fires once a week, e.g. "Mon 08:00"
prompt string yes (always) spliced into target as > [!ai] <prompt>; ignored when action is set, but the key must still be present
target string yes note path the directive lands in (supports {{date}}/{{year}}/{{week}})
action string no run a maintenance pass instead of splicing a prompt (e.g. "lint-semantic")

That’s the whole schema. There is no name, cron, every, agent, do, enabled, jitter, or timezone field.

Time syntax

Two forms:

Examples:

Rule Means
daily: "07:00" Every day at 07:00
daily: "18:30" Every day at 18:30
weekly: "Mon 09:00" Mondays at 09:00
weekly: "Fri 17:00" Fridays at 17:00

There is no cron, no sub-day interval (every), and no per-rule timezone — times are always the system local time of the machine running the schedule.

Target templating

The target path supports three substitutions, resolved at fire time:

Variable Value
{{date}} the fire date as YYYY-MM-DD
{{year}} the fire year as YYYY
{{week}} the week-of-year (system calendar), two digits (e.g. 22)

So target: "Daily/{{date}}.md" lands the directive in today’s daily note. These substitutions apply to target only — the prompt text is spliced verbatim.

When schedules run

A SchedulePoller checks due rules every 60 seconds while the server is up. It runs on lettuce serve (headless) and inside the Mac app.

Schedules catch up exactly once for anything missed while the server was down — when the poller starts it fires the latest eligible occurrence of each rule, not one fire per missed day. There’s no queue of backlogged runs.

State is kept in .lettuce/schedule-state.json (last-fired timestamp per rule, keyed by content so reordering the YAML doesn’t double-fire). Don’t edit it by hand.

Maintenance actions

Instead of a prompt, a rule can carry an action: that runs a built-in maintenance pass on fire — for example the semantic lint health check:

- daily: "03:00"
  target: "Inbox/Lint.md"
  prompt: "(unused when action is set)"
  action: "lint-semantic"

The action files its findings as directives rather than splicing your prompt text. prompt is ignored in this case, but the key must still be present (it’s a required field) — any placeholder string works.

Example: daily digest

- daily: "18:00"
  target: "Daily/{{date}}.md"
  prompt: |
    Create this note if it doesn't exist.
    Frontmatter: title: "{{date}}", tags: [daily].
    Body:
      # Today
      ## Created today
      <bulleted list of notes created today>
      ## Modified today
      <bulleted list>
      ## Tags trending
      <top 5 tags by activity>
    Use the search tool to find today's activity.

The directive lands at Daily/2026-05-29.md; the connected agent does the writing.