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:

  1. curls a POST to create an approval request somewhere durable, getting back an id immediately — this call returns in milliseconds regardless of how long the actual decision takes.
  2. 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 frequent every/cron job 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.