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 sessionIdSynthetic 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.
| Field | Source |
|---|---|
id | openclaw-cron-<8-hex> derived from openclaw job.id |
name | openclaw job.name, prefixed with 🦞 |
schedule | openclaw job.schedule.expr |
enabled | openclaw job.enabled |
triggerType | always "cron" |
modulePath | synthetic://openclaw-cron/<full-jobId> |
steps | EXEC 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.runAtMsfinished_at=entry.runAtMs + entry.durationMsstatus=entry.status === "ok" ? success : failedoutput_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'sfinished_atfinished_at=entry.tsstatus=entry.delivered === true ? success : failedoutput_tail= pretty-printedentry.deliveryresolution (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: falseThe 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
| Path | Role |
|---|---|
optimalOS/workflows/openclaw-cron-mirror.ts | The mirror strand itself (3 steps: JOBS → RUNS → WRITE) |
optimalOS/src/loom/loader.ts | registerSynthetic / unregisterSynthetic / listSyntheticIds / isSynthetic |
optimalOS/src/routes/loom.ts | POST /api/loom/strands/:id/enabled toggle endpoint |
optimalOS/src/server.ts | Boot warmup wiring |
Verifying it works
After a service restart, expect:
# 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:
{
"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.