Skip to content

OpenClaw Cron Mirror

The OpenClaw Cron Mirror surfaces every openclaw cron job as a first-class Loom strand, with full run history, synthesized step trajectory, and two-way enable/disable. OpenClaw stays the source of truth; Loom is the observability and control surface.

Why this exists

OpenClaw runs its own scheduler at ~/.openclaw/cron/jobs.json and persists firings as JSONL at ~/.openclaw/cron/runs/<jobId>.jsonl. Before the mirror, those firings were invisible to Loom — operators had no unified place to see whether a scheduled agent turn ran, succeeded, or got stuck.

The mirror closes that gap without forking the source of truth: openclaw still owns the cron jobs themselves, and Loom is a read-mostly mirror that also pipes enable/disable commands back upstream.

Architecture

openclaw cron jobs (jobs.json)         openclaw cron runs JSONL
        │                                       │
        ▼   list / runs --json                  ▼
┌──────────────────────────────────────────────────────┐
│  workflows/openclaw-cron-mirror.ts   (every 5 min)   │
│                                                      │
│  ┌────────────┐  ┌──────────┐  ┌──────────────────┐  │
│  │ JOBS       │→ │ RUNS     │→ │ WRITE            │  │
│  │ register/  │  │ poll last│  │ INSERT OR IGNORE │  │
│  │ unregister │  │ 10 per   │  │ loom_runs +      │  │
│  │ synthetic  │  │ job      │  │ loom_step_runs   │  │
│  └────────────┘  └──────────┘  └──────────────────┘  │
└──────────────────────────────────────────────────────┘
        │                                       │
        ▼                                       ▼
synthetic strands in                   loom_runs + loom_step_runs
src/loom/loader.ts in-memory           rows keyed by openclaw sessionId

Synthetic strands

Each openclaw cron job is registered in the Loom loader as a synthetic strand — a ValidatedWorkflow with no .ts file backing it. The id format is openclaw-cron-<first-8-hex-of-jobId>, and the modulePath is synthetic://openclaw-cron/<full-jobId> so callers can recognise these and route enable/disable through openclaw instead of trying to edit a workflow file.

FieldSource
idopenclaw-cron-<8-hex> derived from openclaw job.id
nameopenclaw job.name, prefixed with 🦞
scheduleopenclaw job.schedule.expr
enabledopenclaw job.enabled
triggerTypealways "cron"
modulePathsynthetic://openclaw-cron/<full-jobId>
stepsEXEC always, plus DELIVER when the job has a delivery channel

Synthetic registrations survive reloadWorkflows() and refuse to shadow real on-disk strands. The mirror re-registers all jobs every cycle, so renames, reschedules, and external enable-toggles propagate within ≤5 minutes.

Step trajectory

OpenClaw's run JSONL only writes action: "finished" records — there is no per-step trajectory upstream. The mirror therefore synthesizes steps from the rich fields each finished entry carries:

EXEC step (always present):

  • started_at = entry.runAtMs
  • finished_at = entry.runAtMs + entry.durationMs
  • status = entry.status === "ok" ? success : failed
  • output_tail = entry.summary + model=… provider=… durationMs=… tokens=in:N/out:N/total:N

DELIVER step (only when the cron has delivery.channel):

  • started_at = EXEC's finished_at
  • finished_at = entry.ts
  • status = entry.delivered === true ? success : failed
  • output_tail = pretty-printed entry.delivery resolution (intended channel, resolved channel, fallbackUsed, etc.)

Auto-backup and similar maintenance crons that have no delivery target only get the EXEC step.

Idempotency

Each openclaw cron run carries a stable sessionId (UUID). The mirror uses it as loom_runs.run_id and writes via INSERT OR IGNORE. Re-seeing the same JSONL line is a no-op — the database itself is the dedupe ledger, so there is no last-seen.json state file to keep in sync.

Two-way enable / disable

            POST /api/loom/strands/:id/enabled
                   { "enabled": false }
        ┌─────────────────────────────────────────┐
        │ src/routes/loom.ts                      │
        │  if id starts with "openclaw-cron-":    │
        │    execFile("openclaw", ["cron",        │
        │      verb, "--id", <full-jobId>])       │
        └─────────────────────────────────────────┘


              openclaw cron disable --id <jobId>

                          ▼  next mirror tick (≤5min)
              synthetic strand re-registered with
                    enabled: false

The reverse direction (someone runs openclaw cron disable --id … from a terminal) syncs automatically — every 5min the mirror re-builds each synthetic strand from the upstream enabled flag.

The endpoint returns 502 if openclaw is not on PATH or the gateway is unreachable, and 501 if you attempt to toggle a native (non-synthetic) strand. File-backed strands flip their flag in code, so the API does not edit workflow files.

Boot warmup

src/server.ts runs the mirror once via runWorkflow("openclaw-cron-mirror", "cron", "boot-warmup") immediately after loomScheduler.start(). This guarantees synthetic strands exist as soon as the UI loads, instead of waiting up to five minutes for the first scheduled tick. Warmup failures are logged but non-fatal — the next scheduled tick retries.

Files

PathRole
optimalOS/workflows/openclaw-cron-mirror.tsThe mirror strand itself (3 steps: JOBS → RUNS → WRITE)
optimalOS/src/loom/loader.tsregisterSynthetic / unregisterSynthetic / listSyntheticIds / isSynthetic
optimalOS/src/routes/loom.tsPOST /api/loom/strands/:id/enabled toggle endpoint
optimalOS/src/server.tsBoot warmup wiring

Verifying it works

After a service restart, expect:

bash
# 5 synthetic strands (one per openclaw cron job)
sqlite3 ~/.optimalos/optimalos.db \
  "SELECT workflow_id, COUNT(*) FROM loom_runs \
   WHERE workflow_id LIKE 'openclaw-cron-%' GROUP BY workflow_id;"

# Step shape — EXEC for every run, DELIVER only when job has a channel
sqlite3 ~/.optimalos/optimalos.db \
  "SELECT step_id, COUNT(*), GROUP_CONCAT(DISTINCT status) \
   FROM loom_step_runs \
   WHERE run_id IN (SELECT run_id FROM loom_runs \
                    WHERE workflow_id LIKE 'openclaw-cron-_%') \
   GROUP BY step_id;"

# Boot-warmup row in the mirror's own run history
sqlite3 ~/.optimalos/optimalos.db \
  "SELECT run_id, status, trigger_by, started_at FROM loom_runs \
   WHERE workflow_id='openclaw-cron-mirror' \
   ORDER BY started_at DESC LIMIT 3;"

In the Loom tab at https://optimal.miami, look for strands prefixed with 🦞 — they are the mirrored openclaw crons. Click into one to see EXEC + DELIVER step rows for each historical firing.

Manual-run guard

Synthetic-step fn is a sentinel async () => ({ synthetic: true }) so the workflow validator passes. Earlier builds let POST /api/loom/strands/<openclaw-cron-…>/run execute that no-op silently — clicking "Run" in the UI did nothing visible.

src/routes/loom.ts now short-circuits synthetic strands at the /run endpoint with HTTP 409 and a structured hint:

json
{
  "ok": false,
  "error": "synthetic strand cannot be run from Loom",
  "reason": "openclaw cron jobs are mirrored read-mostly; running here would execute a no-op sentinel.",
  "hint": "Run upstream instead: openclaw cron run --id <full-jobId>"
}

To actually fire a mirrored cron job, use openclaw cron run --id <jobId> (the full UUID is recoverable from the strand's modulePath) or wait for its native cron schedule.

Built by Carlos Lenis in Miami