OpenClaw cron jobs can't ask_user: patterns for unattended approvals
A cron'd OpenClaw job has no chat window to ask into. Here's why, and what to do about a step that genuinely needs a human sign-off.
On this page
Why cron sessions are unattended
ask_user works by pausing the current turn and surfacing a prompt in whatever chat
surface the session is attached to — a live conversation with a person typing back. OpenClaw's own
docs describe it as main-session-only for exactly this reason: a job created with
openclaw automations create "<cron>" "<prompt>" --agent ... --session isolated
runs from the Gateway process (which has to be running 24/7 regardless of whether anyone's looking
at a chat window) and gets a fresh cron:<jobId> session with nobody on the other
end. Calling ask_user there has no chat surface to render into and no person to
answer — it's not a bug so much as a category error: the tool assumes an audience that a scheduled,
unattended run structurally doesn't have.
This isn't unique to OpenClaw — it's the same shape of problem as Claude Code's
-p mode having no TTY for an interactive permission prompt (see
that guide) or an Agent SDK daemon's
canUseTool callback having no terminal to draw a dialog in (see
that one). Anywhere a framework's "ask a human" primitive
assumes a live session, a cron'd or otherwise headless run of the same framework needs a different
mechanism.
The poll-by-id pattern
The fix looks the same everywhere this problem shows up: separate "ask the question" from
"wait for the answer" into two calls, so the waiting doesn't have to happen inside one blocking
turn of the same process. For an OpenClaw skill, that means the skill's exec tool
(there's no separate "declare an HTTP call" primitive — a skill calls an API by instructing
exec to run curl, gated by requires.bins: [curl] in its
frontmatter) does two things:
curls aPOSTto create an approval request somewhere durable, getting back an id immediately — this call returns in milliseconds regardless of how long the actual decision takes.- Either polls
GET .../approvals/{id}in a bounded loop within the same run (cron jobs default to a 60-minute timeout, so budget your poll interval and count against that), or exits and lets a separate, more frequentevery/cronjob re-check the same id on its own schedule until it resolves.
Which of the two you pick is a tradeoff: polling in-loop within the original run is simpler to reason about but ties up that run (and its 60-minute budget) the whole time; a second poller job is more moving parts but doesn't block anything and can wait arbitrarily long across many ticks. For a decision that's usually answered within minutes, in-loop polling is fine. For one that might sit for hours (an operator asleep, a multi-day change window), the separate poller is the better fit.
A minimal skill implementing it
# SKILL.md
---
name: deploy-approval
description: Ask a human before deploying, works in both live chat and unattended cron sessions.
version: 1.0.0
user-invocable: true
metadata:
openclaw:
requires:
bins: [curl, jq]
config: [APPROVALS_API_KEY]
---
## Instructions
Before running a deploy, create an approval request and wait for it:
1. `curl -s -X POST "$APPROVALS_URL/v1/requests" -H "authorization: Bearer $APPROVALS_API_KEY" \
-d '{"kind":"approval","title":"Deploy to prod?","timeout_s":3600}'` — save the returned `id`.
2. Poll: `curl -s "$APPROVALS_URL/v1/requests/$id?wait_s=25"` every ~25 seconds (long-polls up to
that long per call) until `status` is no longer `"pending"`, or up to ~50 minutes total to stay
under the cron job's 60-minute default timeout.
3. If `status` is `"approved"`, proceed with the deploy. If `"denied"` or the loop times out
without resolving, stop and report the outcome — do not deploy.
The skill file doesn't need a special "cron mode" branch — the same instructions work whether
a person is watching a live chat (where they might also just answer in the chat, bypassing the poll
entirely once they see the request land) or the session is a completely unattended
cron:<jobId> run. The only thing that has to be true either way is that the
approval step doesn't route through ask_user.
Secrets in openclaw.json
Don't put APPROVALS_API_KEY as a literal string in openclaw.json.
The config supports an indirection object instead:
{
"skills": {
"entries": {
"deploy-approval": {
"apiKey": { "source": "env", "provider": "default", "id": "APPROVALS_API_KEY" }
}
}
}
}
This form only resolves for host-process runs; a sandboxed skill (Docker) needs the key supplied
via sandbox.docker.env instead, since the sandbox doesn't inherit the Gateway
process's own environment. If you want the skill to never see the real key at all — only a
per-run sentinel that a local proxy substitutes for the real credential at the HTTPS layer — that's
what the optional secret-egress proxy is for; worth it once a skill is calling a
production-credentialed API rather than a scoped approvals endpoint.
Publishing the skill
Once it works, clawhub skill publish ./skill --slug your-approval-skill --owner
<owner> --categories automation,agents (max 3 categories from ClawHub's fixed list, up
to 5 free-form topics) gets it into the catalog. New skills start at version 1.0.0 and
go through an automated security review before they're visible on the install surface — expect a
short delay between publish and it showing up for others to install, and don't rely on it being
installable the instant you publish.