Install
openclaw skills install @alexbloch-ia/always-on-agent-opsKeep an OpenClaw agent running 24/7 — loopback gateway blocks inbound webhooks, OAuth expires silently, strict config. Use when crons stop firing. Trigger on "agent went quiet".
openclaw skills install @alexbloch-ia/always-on-agent-opsAn always-on agent does not die loudly. It goes quiet — expired auth, an unloaded plugin, a dispatcher pointing at an id that no longer exists. Everything below exists to make silence impossible.
| Item | What this skill needs |
|---|---|
| Host access | A logged-in GUI desktop session, permanently. The daemon is not headless. |
| Credentials | A gateway token (loopback only), a provider auth profile, and any queue Bearer you add. All in ~/.openclaw/ or the OS keychain — never in git, never in the agent workspace. |
| Data persisted | Cron job definitions + run state (~/.openclaw/cron/jobs.json), the agent workspace and its memory files, logs. |
| Retention — executed, not declared | Logs: 30 days, deleted by scripts/purge-retention.sh (cr-22, weekly). Orphaned lock dirs: 15 min TTL, reclaimed by the dispatcher. Cron run state: life of the job — it goes when you delete the job. Workspace/memory: no automatic purge — cr-22 reports its size so growth cannot stay invisible; the number you keep is yours to set. |
| Network out | Only what YOU configure: the queue you poll, the notification channel you name. Default is no network — with no secrets.env, the preflight emits Human action required and exits 0. |
| Network in | None. The gateway binds loopback. The optional loopback listener (below) is opt-in and never leaves the host. |
| Notifications | ${NOTIFY_CHANNEL} receives short status lines. They leave the machine. Never put secrets, PII, or payload dumps in them. |
Everything a cron reaches out to is a system you are responsible for: honour its terms of service, and for any personal data touched, apply GDPR — legal basis, minimisation, and a stated retention window — before you schedule the first run.
# ClawHub ships text, not file modes: the exec bit does NOT survive install. Without this, a
# cron `kind: "command"` dies with exit 126 and NO status line — the silence this skill prevents.
chmod +x scripts/preflight-queue.sh scripts/event-dispatch.sh \
scripts/oauth-expiry-check.sh scripts/purge-retention.sh # cron-message.sh is sourced: 644
# The scripts read nothing from the network until this file exists.
cat > scripts/secrets.env <<'EOF'
QUEUE_URL=https://api.acme-corp.example/queue
API_TOKEN=replace-me
EOF
chmod 600 scripts/secrets.env # never commit it; it is not shipped with this skill
| Variable | Where it lives | Who substitutes it |
|---|---|---|
QUEUE_URL, API_TOKEN | scripts/secrets.env | the shell, at script run time |
OPENCLAW_HOME, OPENCLAW_AGENT_ID | environment (optional) | the shell; default ~/.openclaw and main |
GATEWAY_TOKEN, MODEL_PRIMARY, MODEL_FALLBACK, RUNTIME_ID, PROVIDER, *_PLUGIN, CHANNEL, NOTIFY_CHANNEL | openclaw.json / job definitions below | you, by hand — OpenClaw does not interpolate ${...} in its config. A literal ${GATEWAY_TOKEN} left in the file is a literal token, and strict validation will not save you from it. |
(third-party, beyond metadata.requires) | an authenticated queue endpoint you own or may call, a model provider account, a notification channel | you, before the first run |
| Trigger | Action |
|---|---|
| "the agent stopped doing anything" | §OAuth first (silent auth death), then openclaw cron list for stuck jobs |
| "gateway won't start after I edited the config" | §Config as contract — strict validation rejects unknown keys |
| "how do I make my SaaS push to the agent?" | §The loopback cascade — you cannot. Invert to outbound poll |
| "my crons burn tokens finding nothing to do" | §Preflight layer — count without an LLM |
| "the cron fires manually but never on its event" | §Gotcha: real UUID job ids vs template ids |
| "setting up a dedicated always-on machine" | §Host constraints, then §Ops checklist |
| Constraint | Consequence you must implement |
|---|---|
| Service needs a logged-in session | A dedicated machine, auto-login, session never locked out. No headless server. Because the session is always unlocked, that machine must be physically secured, full-disk-encrypted, and carry no data unrelated to the agent — auto-login is only acceptable on a host with nothing else to lose. |
| Sleep kills the session | Disable disk/display sleep on AC power. |
| Restart is not automatic | A daily gateway restart cron (cr-20, 06:00) re-arms the loop. |
| A dead job stays dead | A watchdog cron 5 min later (cr-21, 06:05) lists jobs and reruns errored/stuck ones. |
| File-lock races | Keep ~/.openclaw out of any syncing folder (iCloud/Dropbox). |
| Stray env overrides | A launchctl setenv gateway token silently overrides config → permanent "unauthorized". Unset it. |
| Node version drift | nvm install 22 && nvm alias default 22 — check the CURRENT minimum (has been ≥22.x); a version manager keeps it identical across redeploys, the OS package does not. |
sharp fails to install | It compiles against a system libvips. SHARP_IGNORE_GLOBAL_LIBVIPS=1 npm install -g openclaw@latest, then openclaw --version → 2026.6.x (verify the live release; never hardcode it in a runbook). |
| macOS ships bash 3.2 | mapfile/readarray are bash 4+. The shipped scripts avoid them; keep anything you add 3.2-compatible. |
bind: "loopback" (§Config) answers only on 127.0.0.1An anti-SSRF default, correct, and it decides your whole architecture:
| Consequence | What it forces |
|---|---|
| The gateway is unreachable from the public internet | No cloud service can ever push you a webhook — not your SaaS, not a git host, not a form backend. Their POST cannot arrive. |
| Every integration inverts | The agent polls outbound. The host is a client, never a server. |
| Freshness is not free | Push is instant; poll costs one request per interval — and an LLM turn per interval if you are careless. That price is what §Preflight exists to avoid. |
| The source of truth lives on the other side | The agent asks "is there work?", takes one item, acts, acknowledges. No ack = the item stays queued, so a killed run re-appears as work instead of vanishing. Crash-safety is free from that shape alone. |
OpenClaw does expose an inbound hook (POST 127.0.0.1:18789/hooks/agent, Bearer), reachable only from the host: useful for a local process to nudge the agent, useless from the internet. Exposing the control plane beyond loopback to "fix" this trades an SSRF-hardened default for a remote-code surface — don't. For local push, run a listener bound to 127.0.0.1 that authenticates a Bearer and accepts one event name from a closed list hardcoded in the listener, passing it as the dispatcher's only argument: no request body, no free-form arguments, no raw agent turn. scripts/event-dispatch.sh enforces that same closed list — an unrecognised event exits 2 without touching a job.
~/.openclaw/openclaw.json (JSON5) is strictly validated: one unknown key, one wrong type, and the gateway refuses to start. OpenClaw keeps a last-known-good copy and restores it when the file is broken — so a bad edit can leave you running yesterday's config while you read today's file. Confirm with a restart, never by reading.
{
gateway: {
port: 18789, mode: "local", bind: "loopback", // "local" mandatory; anything else = damaged
auth: { mode: "token", token: "${GATEWAY_TOKEN}" },
nodes: { denyCommands: ["camera.snap", "screen.record", "sms.send", // capture/publish/agenda
"contacts.add", "calendar.add", "reminders.add"] },
},
agents: {
defaults: {
model: { primary: "${MODEL_PRIMARY}", fallbacks: ["${MODEL_FALLBACK}"] },
models: { // MANDATORY allowlist: primary+fallbacks must appear here
"${MODEL_PRIMARY}": { agentRuntime: { id: "${RUNTIME_ID}" } },
"${MODEL_FALLBACK}": { agentRuntime: { id: "${RUNTIME_ID}" } } },
workspace: "${OPENCLAW_HOME}/workspace" },
list: [{ id: "main" }] },
plugins: { entries: { // EXCLUSIVE allowlist — omitted plugin = not loaded, silently
"${NOTIFY_PLUGIN}": { enabled: true }, // example — your chat/notification transport
"${PROVIDER_PLUGIN}": { enabled: true }, // example — your model provider
"${RUNTIME_PLUGIN}": { enabled: true }, // the runtime named by agentRuntime.id above; without
} }, // it, agentRuntime never executes
tools: { profile: "restricted" }, // narrowest profile that boots — jobs inherit THIS
commands: { native: "auto", restart: true },
}
| Rule | Failure mode if ignored |
|---|---|
| Strict validation | Unknown key → gateway won't boot. openclaw doctor --fix strips unknown keys (it lists them) and writes openclaw.json.bak. Read the diff — it deletes, it does not ask. |
plugins is an exclusive allowlist | A plugin you forgot simply doesn't load. No error. The feature is just absent. |
agents.defaults.models is an allowlist | Model in primary/fallbacks but not listed → Model not allowed, thrown before any response. |
denyCommands | Without it, a default set may allow capture/publish/agenda actions your always-on agent can fire unattended. |
commands.allowFrom | If set, it becomes the only authorization source and cancels pairing/allowlists. Don't set it without a reason. |
| Restart rule | Changes to gateway.*, plugins.*, browser.* need openclaw gateway restart. Others may hot-reload. When in doubt: restart. |
tools.profile is inherited | A job with no explicit payload.tools runs with the global profile. That is why the global must be the narrowest one that boots, never the widest: per-job grain is then a deliberate widening, not a hoped-for restriction. |
Auth = which account pays (a provider profile: OAuth or an API key). Runtime = which executor runs the agentic turn (agentRuntime.id). Configuring one does not configure the other. All four must be present together — miss one and it breaks before the first response:
| # | Ingredient | Miss it → |
|---|---|---|
| 1 | model.primary in the live provider namespace | wrong/dead provider |
| 2 | agentRuntime.id set per model in agents.defaults.models | no executor |
| 3 | The runtime plugin enabled in plugins.entries | runtime never loads (allowlist) |
| 4 | auth.order.<provider> lists the profile | a profile outside order is never tried |
openclaw models list --provider ${PROVIDER} # never copy model names from a runbook — enumerate
# Output: no results <- legacy <provider>-<runtime>/* namespace is dead; `doctor --fix` rewrites it
For an unattended host, use an API key. It is the supported path: pay-per-token, no expiry, no human in the loop. OAuth is subscription-priced but expires in days and needs a browser — on a 24/7 host you trade money against a recurring silent outage, and the outage wins. auth-profiles.json (in ~/.openclaw/agents/<agent>/agent/) is a credential, not a config file — treat it like a private key: never in git, no SecretRef support. Copying it to another host is gray-area and not the recommended path: only acceptable if your provider's terms allow that account on that machine. Check the terms before you copy.
A subscription OAuth token expires in days and does not refresh headless. The daemon keeps running; every turn just fails. Nobody is watching at 03:00. This is the single most common way an always-on agent dies.
OPENCLAW_HOME=~/.openclaw ./scripts/oauth-expiry-check.sh
# EARLIEST epoch expiry wins: a fresh profile must never mask a dead one, nor a 90-day refresh token
# a dead access token. It names the deciding `field` and NEVER exits non-zero. "Cannot determine" is
# NEVER `ok`: corrupt JSON, no python3, an unreadable (ISO-8601) expiry all emit `warn` — the mtime
# proxy is reached ONLY when the file parses and genuinely carries no expiry field at all.
# Output: {"status":"warn","source":"expires_at","field":"profiles.dead.expires_at","remaining_days":-1,"note":"...relogin..."}
# Output: {"status":"warn","source":"unresolved","reason":"corrupt_json","note":"Cannot verify token expiry..."}
Wire it to cr-23 (below), whose only instruction is: run the script, and if status is warn or missing, alert ${NOTIFY_CHANNEL} that a human relogin is needed. The cron must not attempt the login itself — that flow needs a real browser. Alert on warn; stay silent on ok.
The scheduler is in-process in the gateway; jobs persist to ~/.openclaw/cron/jobs.json. No system crontab.
{ id: "cr-10-worker", agentId: "main", enabled: true,
schedule: { kind: "cron", expr: "0 9-18 * * 1-5", tz: "UTC" }, // IANA tz; or kind:"every"/"at"
sessionTarget: "isolated", wakeMode: "now", // isolated = inherits no context, no credentials-in-
payload: { kind: "agentTurn", message: "…", // conversation, no half-done state from another run
model: "${MODEL_PRIMARY}", fallbacks: ["${MODEL_FALLBACK}"],
tools: ["exec", "read"], // ALWAYS explicit: a read-only audit and a job that
thinking: "medium", // pushes branches must not share an authority
timeoutSeconds: 2400 }, // surface just because they share a host
delivery: { mode: "announce", channel: "${CHANNEL}", to: "${NOTIFY_CHANNEL}" },
failureAlert: { after: 2, to: "${NOTIFY_CHANNEL}", cooldownMs: 1800000 } }
The four housekeeping jobs the §Ops checklist watches — argv[0] is an absolute path in every case, a cron's PATH is not your shell's — then the four laws for anything that runs unattended:
| id | Schedule | Payload |
|---|---|---|
cr-20-restart | { kind: "cron", expr: "0 6 * * *", tz: "UTC" } | { kind: "command", argv: ["/opt/homebrew/bin/openclaw", "gateway", "restart"] } |
cr-21-watchdog | { kind: "cron", expr: "5 6 * * *", tz: "UTC" } | { kind: "agentTurn", tools: ["exec"], message: "Run openclaw cron list. Rerun errored/stuck jobs. Report one status line." } |
cr-22-purge | { kind: "cron", expr: "0 5 * * 0", tz: "UTC" } | { kind: "command", argv: ["/abs/path/to/scripts/purge-retention.sh"] } |
cr-23-oauth | { kind: "cron", expr: "0 8 * * *", tz: "UTC" } | { kind: "agentTurn", tools: ["exec"], message: "Run scripts/oauth-expiry-check.sh. If status is warn or missing, alert that a human relogin is needed. Silent on ok." } |
scripts/cron-message.sh. Without it, ten jobs invent ten formats and the channel becomes unreadable within a week.<job-id> - <Done | Nothing to do | Started | Blocked | Human action required | Error>
Result: <one factual sentence>
Item: <what was touched>
Next action: <what happens next, or "none">
Details: <short key=value context, never a secret>
dispatch - Started <- emitted by the DISPATCHER when a preflight counts > 0;
Result: Job requested. the preflight stays silent and execs it. Verified output.
Item: cr-10-worker
Next action: OpenClaw runs it now.
Details: event=work_ready count=2
Stop paying an LLM turn to discover there is nothing to do. Split every heavy job in two: the preflight counts and, if count > 0, calls the dispatcher with a named event; the dispatcher runs the matching job(s) now. The heavy job keeps a widely-spaced every schedule as a safety net for the day a preflight silently breaks. Result: near-event-driven latency at poll-loop cost, with a floor under it.
| Layer | Kind | Frequency | Cost |
|---|---|---|---|
pf-* preflight | kind: "command" (argv → shell script) | every 5–15 min | one curl + a count. Zero LLM. |
cr-* heavy job | kind: "agentTurn" | every 6h fallback only | a full agent turn |
{ id: "pf-10-queue", enabled: true, schedule: { kind: "every", ms: 600000 }, // 10 min
payload: { kind: "command", argv: ["/abs/path/to/scripts/preflight-queue.sh"] } } // cron PATH != yours
THE PREFLIGHT DECIDES NOTHING. IT COUNTS. It must never claim an item, mutate state, or reason about what to do — the moment a preflight starts judging, you have a second, cheaper, untested agent making production decisions in bash. Count, dispatch, exit.
every does not spread a fleet — it phase-locks it. The table above tells you to give each heavy job a widely-spaced every as its safety net. Do that for five jobs in one provisioning pass and all five fire together, forever. Interval schedules do not drift: equal intervals preserve whatever phase the jobs were created with, and a fleet provisioned in one pass was created seconds apart. Pick a round anchor and it is worse — five jobs at everyMs: 21600000 created on a timestamp that divides evenly by 6h land on the same 00/06/12/18 grid to the second. Five agent turns spawning worktrees, installs and a browser at the same instant on one host is a safety net that has become the outage.
Two things sharpen it. The fallback bypasses your only concurrency guard: the scheduler fires the job directly, so it never passes through event-dispatch.sh and neither the mkdir lock nor the runningAtMs check is in the loop — the one moment the fleet is most likely to collide is the one moment nothing is guarding it. And the daily restart re-anchors: cr-20 reloads every job at once, so a fleet you hand-staggered can silently re-converge after a reboot.
| Do | Not |
|---|---|
Give each heavy fallback an explicit cron expression on its own minute — the way cr-20…cr-23 above already are (0 6, 5 6, 0 5, 0 8). openclaw cron list then shows you the phase plan. | Five every: 6h jobs and the assumption they will drift apart. They will not. |
staggerMs if your release carries it — confirm with openclaw cron get <id> on the version you actually run, never from a runbook. | A phase plan that exists only as arithmetic nobody re-derives at 03:00. |
Verify rather than trust the shape: openclaw cron list, read the next-run times, and check that no two heavy jobs share a minute. If they do, you have not built a safety net — you have built a thundering herd with a 6-hour period.
./scripts/preflight-queue.sh
# Tolerant count: numeric `count`, else the SHALLOWEST array anywhere (nested {data:{items:[…]}} still
# counts; an API renaming `items` to `results` degrades to a count, not a crash loop). "Counted zero"
# is NOT "found nothing to count": an empty array is 0, but no-count-and-no-array — a 200 carrying
# {"error":"unauthorized"}, 502 HTML, a captive portal — is an Error. A green "Nothing to do" while the
# queue fills is worse than a crash. No secrets.env, dead host, junk body -> one status line, exit 0,
# never a bare shell error. Hence no `set -e`: a broken preflight must still produce a line.
# Output: pf-10-queue - Nothing to do / Result: Queue empty. / ... / Details: count=0
# Output: pf-10-queue - Error / Result: Payload carries no count and no array... / Details: reason=no_countable_field
scripts/event-dispatch.sh maps events → jobs, with three guards:
mkdir lock per job id — atomic, no race, released on RETURN, EXIT, INT and TERM, plus a TTL. trap ... RETURN alone is not enough: a kill -9, the 06:00 restart or a sleeping machine leaves the dir behind and blocks that event forever, while the channel keeps printing a healthy-looking Blocked. A lock older than LOCK_TTL_MIN (default 15) is reclaimed.openclaw cron get <id> first. A non-zero exit, an empty answer, or a reply that is not JSON carrying that id is an Error line naming the fix. It classifies into exactly unknown | running | idle, so a release whose cron get exits 0 and prints prose ("job not found") cannot fall through to a run on a bogus id. Verify yours: openclaw cron get nope; echo $?.runningAtMs guard — if the job is mid-run, report Blocked and return 0. Never stack turns on a running job.One event may fan out to several jobs (source_changed → reviewer + drift-watch). The shipped mapping's cr-30-reviewer and cr-40-drift-watch are illustrative placeholders — this skill defines only cr-10-worker and cr-20…cr-23; until you point them at jobs you installed, those two events dispatch to nothing but an Error line (which is the designed failure, not a silent one). Keep the event vocabulary about what happened in the world (work_ready, review_needed, source_changed), never about which job you want to run — that's the mapping's job, and it's the only place that changes when you rename a cron.
| Symptom | Root cause | Fix |
|---|---|---|
| Event dispatch reports success, job never runs | openclaw cron add accepts no --id: the installed job gets an auto-generated UUID, and its name is prefixed. Your template's tidy cr-30-reviewer exists only in your file. The dispatcher's openclaw cron run cr-30-reviewer targets a job that was never installed — and it fails in a way that reads like nothing happened. Every job created via CLI is affected; only hand-seeded jobs.json entries keep their ids. | openclaw cron list, copy the real ids, and point the mapping at those. Re-check after any re-provisioning: new install, new UUIDs. |
| Script dies with no status line, exit 126 | The exec bit did not survive install — ClawHub ships text, not modes | chmod +x scripts/*.sh (§Setup). cron-message.sh stays 644 |
| Feature configured but absent, no error | plugins allowlist is exclusive — the entry is missing | Add it to plugins.entries and restart |
Model not allowed before any answer | Model used but not in agents.defaults.models | Add it with its agentRuntime |
| Gateway won't boot after an edit | Strict validation rejected an unknown key | openclaw doctor --fix, read the stripped-key list and the .bak |
| Config edited, behaviour unchanged | Last-known-good restored, or gateway.*/plugins.* needs a restart | openclaw gateway restart, then verify live state |
| Persistent "unauthorized" on loopback | A launchctl setenv token overrides the config file | Unset it, restart |
Dispatch always says Blocked, no job ever runs | A killed run (restart, sleep, kill -9) orphaned its lock dir; the channel looks healthy | rm -rf "${TMPDIR:-/tmp}"/openclaw-event-*.lock. The TTL reclaims it after LOCK_TTL_MIN; lower it if your jobs are short |
cr-23 says ok while every turn fails | Several profiles in one auth file, and a fresh one masked the dead one — the shipped checker takes the earliest expiry precisely for this | Read field in the JSON: it names the profile that decided. If you wrote your own checker, take the min — and make "cannot determine" a warn, never an ok |
cr-22 reports Not an OpenClaw home | OPENCLAW_HOME points somewhere without openclaw.json or agents/ — a typo or a stale env var | Intended: the purge refuses any dir it cannot prove is an agent home rather than deleting on faith. Fix the variable |
| All runs fail overnight, no alert | OAuth expired headless | cr-23 + human relogin; consider an API key |
| Notification channel unreadable | Jobs invented their own formats | One cron-message.sh, no exceptions |
| Watch | Cadence | Symptom → cause |
|---|---|---|
openclaw gateway status | daily (cr-20 restart 06:00) | not up → session logged out / machine slept |
openclaw cron list | daily (cr-21 watchdog 06:05) | errored/stuck jobs → rerun; missing next-run → scheduler restart |
oauth-expiry-check.sh | daily (cr-23 08:00) | warn/missing → human relogin, today |
Preflight + dispatch status lines | continuous | reason=no_countable_field → the endpoint changed shape or the token died (it self-reports; it does not print a green Nothing to do); dispatch - Error → mapping points at dead ids |
| Heavy-job fallback runs | weekly | fallback firing every time → the preflight is broken, fix the preflight |
| Logs + workspace size | weekly (cr-22 purge 05:00 Sun) | logs past 30d → purged automatically; workspace growing → set your own bound |
openclaw --version + Node | on update | after openclaw update: doctor, gateway restart, health |
This skill ONLY: configures an OpenClaw host you control; polls endpoints you own or are authorized to call; schedules jobs with per-job tool restrictions; monitors auth expiry and alerts a human; deletes only its own logs past the stated window; emits normalized status lines.
This skill NEVER: exposes the control plane beyond loopback; bypasses an anti-abuse control, rate limit, or authentication check — when a defence triggers, the run stops and pings a human instead of adapting; performs an irreversible action unattended; conceals a failure behind an ack; writes secrets into the workspace, the notification channel, or git.
Alexandre Bloch — Bloch Agents · ClawHub @AlexBloch-IA