Skip to content

Scheduling Workflows

Run agents, commands, playbooks, and flows on a schedule — no chat session, no babysitting. This guide is the shortest path from nothing to a working scheduled workflow, using only commands that exist today.

Prerequisites

Start the Studio daemon once (it hosts the scheduler engine):

li studio start          # backend on 127.0.0.1:8765
curl -s 127.0.0.1:8765/api/admin/health   # verify it is up

li studio start runs the server in the foreground — it does not daemonize. Run it under your init system (launchd/systemd) or detach it yourself (setsid/start_new_session) for anything long-lived; a terminal or task runner that later reaps its children takes every schedule down with it.

One-off schedules: typed quick-create

The fastest way to schedule a single thing. Each action kind is a subcommand with only the flags that kind actually needs:

# An agent run every morning at 06:00 New York time
li schedule create agent nightly-review \
  --profile reviewer --prompt-file review-prompt.txt \
  --cron "0 6 * * *" --timezone America/New_York

# A command every 15 minutes (the executable goes after a trailing --).
# Command actions are security-gated: the executable name must appear in
# LIONAGI_SCHEDULER_COMMAND_ALLOWLIST (comma-separated) in the daemon's
# environment, must be a bare PATH-resolvable name (no path separators),
# and arguments must be positional — tokens starting with '-' are rejected.
li schedule create command refresh-index --every 15m -- refresh-index nightly

# A flow document fired once at an absolute instant
li schedule create flow release-pipeline \
  --file pipelines/release.flow.yaml \
  --at 2026-08-01T09:00:00-04:00

# A playbook fired when PRs change on a repository
li schedule create playbook pr-triage \
  --playbook triage --github myorg/myrepo --github-filter '{"state": "open"}'

Notes that save debugging time:

  • --cron requires --timezone (an IANA name). Expressions resolve in that timezone, DST-aware. There is no silent UTC surprise.
  • --at demands a full RFC 3339 instant with a UTC offset; it implies max-runs 1.
  • The working directory is captured at creation time (--cwd, defaulting to where you ran the command) and persists with the schedule. Where the daemon was started no longer matters for new schedules.
  • Guardrails are flags, not afterthoughts: --max-runs, --budget-usd, --budget-tokens, --overlap skip|allow, --missed-fire skip|run_once.

Recurring automation as one file: ScheduleSet

For anything beyond a couple of ad-hoc entries, declare all schedules in one YAML document and reconcile it. The file is the source of truth; applying it creates, updates, and (for rows the file owns) disables schedules atomically.

apiVersion: lionagi.io/v1alpha1
kind: ScheduleSet
metadata:
  name: automation
  project: demo
schedules:
  nightly-review:
    trigger:
      cron:
        expression: "0 6 * * *"
        timezone: America/New_York
    target:
      kind: agent
      profile: reviewer
      prompt: "Review yesterday's changes and write a summary."
    policies:
      overlap: skip
      budget:
        usd: 2.0
  refresh-index:
    trigger:
      every: 15m
    target:
      kind: command
      executable: refresh-index   # must be in LIONAGI_SCHEDULER_COMMAND_ALLOWLIST
      args: ["nightly"]           # positional tokens only; '-' prefixes rejected
    notify:
      "on": [failed]
      command: "notify-send 'refresh-index failed'"

Validate without touching the database, preview the reconciliation, then apply:

li schedule validate schedules.yaml
li schedule apply schedules.yaml --dry-run
li schedule apply schedules.yaml

Notes:

  • Quote the notify "on" key. Unquoted on: is YAML 1.1 boolean true and the parser will reject the document with a pointed error.
  • notify.on takes terminal statuses (completed, failed, ...); the command runs once per matching terminal event, and a notify failure never affects the run's own outcome.
  • Target kinds: agent, command, playbook, flow. A schedule this set created that later disappears from the file is disabled on the next apply (not deleted — its run history stays queryable).

Event-driven recipe: review every PR change

A github trigger polls a repository and fires once per pull request that changed since the last poll — new PRs and new pushes to open PRs both count. The event's fields are available to the agent prompt as {{var}} templates: pr_number, pr_title, pr_url, pr_author, head_sha, draft, is_same_repo.

li schedule create agent pr-review \
  --profile reviewer \
  --prompt 'Review PR #{{pr_number}} ("{{pr_title}}") at {{pr_url}}, head {{head_sha}}.
Read the full diff, then post one review comment: verdict, the head SHA you
reviewed, and any findings with file:line anchors.' \
  --github myorg/myrepo \
  --github-filter '{"draft": false, "same_repo_only": true}'

Two filter notes that are really safety notes:

  • "same_repo_only": true excludes PRs from forks. Fork PRs carry untrusted content — code, PR bodies, and comments can all embed instructions aimed at your reviewing agent — so keep automated agents off them and route fork PRs to a human instead.
  • "draft": false skips drafts, so authors can push work-in-progress without burning review runs.

Pinning the reviewed head_sha into the review output is what makes automated reviews trustworthy: a verdict is only meaningful for the exact commit it read, and the next push fires a fresh run with a fresh SHA.

One-shots and bounded schedules

Three ways to bound how often a schedule fires, and when each fits:

  • --at <instant> — a point-in-time trigger. Fires exactly once at that instant (it implies max-runs 1). The right tool when you know the wall-clock time.
  • --every ... --once (sugar for --max-runs 1) — "fire once, as soon as the scheduler picks it up." The idiom for launching a long job detached from your terminal: create it with a short interval and --once, and the first tick runs it.
  • --max-runs N — a lifetime cap. The schedule auto-disables once N runs have fired; re-enabling it later does not reset the counter.

How the budget is counted, because it matters for long-running actions:

  • A run consumes budget when it fires, not when it finishes. In-flight (running) and reaped (timed_out) runs count, so a one-shot whose action is still executing — or whose action timed out — never fires a second time.
  • Overlap skip rows do not count: while a long one-shot is still executing, an interval trigger keeps ticking and records a skipped row per tick (visible in li schedule runs). That is bookkeeping noise, not extra work — the action ran once. If the rows bother you, --at a real instant instead of polling with an interval.

Measuring schedules: cost, time, and model fit

Every scheduled spawn records what it used: the spawned session rows in ~/.lionagi/state.db carry model, effort, input_tokens, output_tokens, total_cost_usd, and start/end timestamps, and each schedule_runs row links to its invocation. That makes a schedule a measurable recipe: a pinned combination of task, prompt, model, and effort whose cost and outcome accumulate run over run.

Read it back with a read-only query:

sqlite3 "file:$HOME/.lionagi/state.db?mode=ro" "
  SELECT sc.name,
         COUNT(*)                              AS runs,
         ROUND(SUM(s.total_cost_usd), 2)       AS usd,
         SUM(s.output_tokens)                  AS out_tokens,
         ROUND(AVG(s.ended_at - s.started_at)) AS avg_secs
  FROM schedule_runs r
  JOIN schedules  sc ON sc.id = r.schedule_id
  JOIN sessions   s  ON s.invocation_id = r.invocation_id
  WHERE r.fired_at > strftime('%s','now','-7 days')
  GROUP BY sc.name ORDER BY usd DESC;"

What to do with the numbers: for each recurring task, start with the cheapest model tier you believe could do it, and let the run history argue. If a recipe succeeds consistently, try the next tier down; if it fails or needs rework, escalate one tier and re-measure. Model routing decided by accumulated per-recipe evidence beats a static "always use the big model" rule — most recurring automation (report generation, triage, polling probes, draft passes) is exactly where smaller models earn their keep, and the schedule is the natural unit to prove it per task rather than argue it in general.

Did it work?

li schedule list                 # everything, with enabled state and trigger kind
li schedule status <id>          # "did it work" summary for one schedule
li schedule runs <id> --limit 10 # recent runs
li schedule run <run-id>         # one run in detail
li schedule trigger <id>         # fire now, without waiting for the trigger
li monitor run <run-id>          # block until a run reaches a terminal state

For runs nobody watches live, make artifact presence part of your triage: a completed status with a missing artifact is the classic silent failure of any recurring system.

Stopping things

li schedule disable <id>   # keep the row, stop firing
li schedule delete <id>    # remove it
li kill <run-or-session-id>  # stop an in-flight run, reaping detached workers

When something misbehaves

  • li schedule get <id> shows the resolved trigger and next fire time — check next_fire_at first; a one-shot whose instant already passed will not fire.
  • Schedule spawns run li from the schedule's stored working directory. After reinstalling lionagi into the environment a schedule uses, run li --version there once — an import-broken install fails at spawn, which otherwise surfaces only as failed runs.
  • li schedule limits shows the global concurrent-fire cap if runs seem queued.