Summary
Cron jobs that intentionally produce an empty assistant reply (silent alert channels — "watch X, say nothing unless there's something to report") fail every run with:
FailoverError: CLI backend returned an empty response. (reason: empty_response, lane: "cron")
even though the underlying claude CLI returns is_error: false / subtype: success. The job is marked error in openclaw cron list, and the Gateway logs a failover error on every scheduled tick.
Pre-2026.6.6 an intentionally-empty cron run was correctly classified ok / not-delivered. Since then, the empty-reply guard throws for the cron lane because nothing ever opts the cron lane into "empty reply is allowed / silent".
Why it matters
Silent-by-default alert crons are a core pattern (price/uptime/news watchers that only message when there's something to say). Right now every such job:
- spams the Gateway log with
FailoverError on every tick,
- shows permanently
error status in cron list (masking real failures),
- and depending on delivery config can trigger failure-destination routing for a non-failure.
There is no settings-level workaround — allowEmptyAssistantReplyAsSilent is never exposed for cron jobs.
Reproduction shape
- backend:
claude-cli (bundled @anthropic-ai/claude-code)
- delivery:
announce -> telegram:<id> (also reproduces with other targets)
- a cron job whose prompt instructs the agent to reply with nothing when there's nothing to report, e.g. schedule
0 8,11,14,17,20,23 * * *, lane cron, isolated session
- on any tick where the agent legitimately produces no outbound text →
FailoverError: CLI backend returned an empty response.
openclaw cron show <id> → status: error
Root cause (from inspection of the shipped 2026.6.8 bundle)
-
Throw site (cli-runner-*.js, the CLI messaging-delivery finalizer):
if (!assistantText && !output.didSendViaMessagingTool && params.allowEmptyAssistantReplyAsSilent !== true)
throw attachCliMessagingDeliveryEvidence(new FailoverError("CLI backend returned an empty response.", {
reason: "empty_response",
provider: params.provider,
model: context.modelId,
sessionId: params.sessionId,
lane: params.lane, // === "cron" for cron jobs
...
}));
So an empty reply is only tolerated when params.allowEmptyAssistantReplyAsSilent === true.
-
The flag is only ever computed on the interactive reply path (get-reply-*.js), and purely from DM/group chat semantics:
const allowEmptyAssistantReplyAsSilent =
(isDirectChat && silentReplyConversationType === "direct" && silentReplySettings.policy === "allow")
|| (isGroupChat && resolveGroupSilentReplyBehavior({ sessionEntry, defaultActivation, silentReplyPolicy: ... }));
For a cron run, isDirectChat and isGroupChat are both false → the flag is falsy.
-
The cron invocation path never sets it. server-cron-*.js dispatches the run with lane: "cron" but does not compute or pass allowEmptyAssistantReplyAsSilent, so it defaults falsy and the throw in (1) fires for every intentionally-silent cron tick. There is also no per-job field on the cron schema (jobs-* store) to opt in.
Net effect: the cron lane has no path — config or code — to say "an empty reply here is intentional, deliver nothing and succeed."
Proposed fix
Any of (preferably A + C):
- A. In the cron dispatch path (
server-cron, where lane: "cron" is set), default allowEmptyAssistantReplyAsSilent: true — a cron tick producing no message is the intended silent-alert behavior, not a backend failure.
- B. Honor the lane at the throw site:
... && params.allowEmptyAssistantReplyAsSilent !== true && params.lane !== "cron".
- C. Expose a per-job boolean on the cron schema (e.g.
silentWhenEmpty / allowEmptyReply, default true) that flows into the run params, so a job can opt out and treat empty as failure if desired.
Workaround in use
Patching the shipped bundle to skip the throw when params.lane includes "cron" (re-applied after each upgrade, since dist/ is overwritten):
// at the throw site in cli-runner-*.js
params.allowEmptyAssistantReplyAsSilent !== true && !String(params.lane || "").includes("cron")) throw ...
After patching, the same job's status flips error → ok and the silent tick delivers nothing, as expected pre-2026.6.6.
Environment
- OpenClaw 2026.6.8 (
844f405)
- macOS (Apple Silicon), global Homebrew install (
/opt/homebrew/lib/node_modules/openclaw)
- backend:
claude-cli (@anthropic-ai/claude-code)
- delivery:
announce -> telegram
- Regression window: worked ≤ 2026.6.5, broken from 2026.6.6 onward.
Summary
Cron jobs that intentionally produce an empty assistant reply (silent alert channels — "watch X, say nothing unless there's something to report") fail every run with:
even though the underlying
claudeCLI returnsis_error: false/subtype: success. The job is markederrorinopenclaw cron list, and the Gateway logs a failover error on every scheduled tick.Pre-2026.6.6 an intentionally-empty cron run was correctly classified
ok/ not-delivered. Since then, the empty-reply guard throws for the cron lane because nothing ever opts the cron lane into "empty reply is allowed / silent".Why it matters
Silent-by-default alert crons are a core pattern (price/uptime/news watchers that only message when there's something to say). Right now every such job:
FailoverErroron every tick,errorstatus incron list(masking real failures),There is no settings-level workaround —
allowEmptyAssistantReplyAsSilentis never exposed for cron jobs.Reproduction shape
claude-cli(bundled@anthropic-ai/claude-code)announce -> telegram:<id>(also reproduces with other targets)0 8,11,14,17,20,23 * * *, lanecron, isolated sessionFailoverError: CLI backend returned an empty response.openclaw cron show <id>→status: errorRoot cause (from inspection of the shipped 2026.6.8 bundle)
Throw site (
cli-runner-*.js, the CLI messaging-delivery finalizer):So an empty reply is only tolerated when
params.allowEmptyAssistantReplyAsSilent === true.The flag is only ever computed on the interactive reply path (
get-reply-*.js), and purely from DM/group chat semantics:For a cron run,
isDirectChatandisGroupChatare both false → the flag is falsy.The cron invocation path never sets it.
server-cron-*.jsdispatches the run withlane: "cron"but does not compute or passallowEmptyAssistantReplyAsSilent, so it defaults falsy and the throw in (1) fires for every intentionally-silent cron tick. There is also no per-job field on the cron schema (jobs-*store) to opt in.Net effect: the cron lane has no path — config or code — to say "an empty reply here is intentional, deliver nothing and succeed."
Proposed fix
Any of (preferably A + C):
server-cron, wherelane: "cron"is set), defaultallowEmptyAssistantReplyAsSilent: true— a cron tick producing no message is the intended silent-alert behavior, not a backend failure.... && params.allowEmptyAssistantReplyAsSilent !== true && params.lane !== "cron".silentWhenEmpty/allowEmptyReply, defaulttrue) that flows into the run params, so a job can opt out and treat empty as failure if desired.Workaround in use
Patching the shipped bundle to skip the throw when
params.laneincludes"cron"(re-applied after each upgrade, sincedist/is overwritten):After patching, the same job's status flips
error→okand the silent tick delivers nothing, as expected pre-2026.6.6.Environment
844f405)/opt/homebrew/lib/node_modules/openclaw)claude-cli(@anthropic-ai/claude-code)announce -> telegram