Coding-Agent Skill Notifications Silently Dropped Due to Default heartbeat.target Configuration
Background task completion notifications from the coding-agent skill fail silently because heartbeat.target defaults to 'none', causing LLM responses to be discarded before delivery.
๐ Symptoms
Primary Manifestation
When a background coding-agent task completes, the expected notification message never arrives in any channel (terminal, UI, or external integration).
Technical Error Output
The heartbeat fires and LLM generates a response, but delivery is silently suppressed. No error is logged to console. Debug-mode inspection reveals:
// Verbose log output (if DEBUG=openclaw:heartbeat is enabled)
[openclaw:heartbeat] Resolving delivery target for system event heartbeat
[openclaw:heartbeat] target config: "none" (default)
[openclaw:heartbeat] Delivering to: NoHeartbeatDeliveryTarget { reason: "target-none" }
[openclaw:heartbeat] Response generated but discarded - no valid delivery target
// Standard log output - nothing appears
// User sees: (silence)Reproduction Steps
# 1. Verify default configuration
openclaw config get heartbeat.target
# Output: none
# 2. Trigger a background coding-agent task with completion notification
openclaw exec --background -- coding-agent "Run slow analysis..."
# 3. Wait for completion (task finishes successfully)
# 4. Observe: No "Done: ..." message received
# 5. Check task status
openclaw task status --last
# Output: status: "completed", notifications: []Secondary Indicators
- The
maybeNotifyOnExit()handler for backgrounded exec processes also exhibits silent failure - Manual invocation of
openclaw system event --text "Test" --mode nowproduces no visible output - Config inspection confirms no
heartbeat.targetoverride exists in user’s config file
๐ง Root Cause
Architectural Overview
The notification flow involves three interconnected components:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ NOTIFICATION FLOW DIAGRAM โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ โ
โ Coding-Agent Skill โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ openclaw system event --text "Done: ..." --mode now โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ enqueueSystemEvent({ text, mode: "now" }) โ โ
โ โ Source: pi-embedded-*.js:maybeNotifyOnExit() โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ requestHeartbeatNow() โ โ
โ โ Source: heartbeat system โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Heartbeat fires โ LLM processes system event โ โ
โ โ Source: reply-*.js:heartbeat handler โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ resolveHeartbeatDeliveryTarget() โ โ
โ โ Source: reply-*.js:resolveHeartbeatDeliveryTarget() โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโ โ
โ โผ โผ โผ โ
โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โ โ target: โ โ target: โ โ target: โ โ
โ โ "last" โ โ "none" โ โ "session" โ โ
โ โโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโค โ
โ โ DELIVER โ โ DISCARD โ โ DELIVER โ โ
โ โ response โ โ response โ โ response โ โ
โ โ silently โ โ silently โ โ to session โ โ
โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโFailure Sequence
Step 1: Default Configuration
The heartbeat.target configuration option defaults to "none" in src/config/defaults.ts:
// src/config/defaults.ts (line ~47)
export const defaultConfig = {
// ...
heartbeat: {
target: "none", // โ THIS IS THE CULPRIT
interval: 30000,
// ...
},
// ...
};Step 2: Delivery Target Resolution
When resolveHeartbeatDeliveryTarget() is invoked, it reads the config:
// reply-*.js (line ~26974 in dist, or src/core/reply.ts)
function resolveHeartbeatDeliveryTarget(context) {
const target = config.heartbeat?.target ?? "none";
switch (target) {
case "last":
return buildLastChannelTarget(context);
case "session":
return buildSessionTarget(context);
case "none":
default:
return buildNoHeartbeatDeliveryTarget({ reason: "target-none" });
}
}Step 3: Silent Discard
The NoHeartbeatDeliveryTarget object instructs the delivery subsystem to:
// reply-*.js
function buildNoHeartbeatDeliveryTarget({ reason }) {
return {
type: "none",
reason,
deliver: (response) => {
// Silently discard - no logging at INFO level
debug(`Heartbeat response discarded: ${reason}`);
return { delivered: false, reason };
}
};
}Step 4: Affected Code Paths
Both the coding-agent skill and internal handlers share this code path:
// pi-embedded-*.js (line ~15413)
// maybeNotifyOnExit() - handles background exec process completion
function maybeNotifyOnExit(pid, exitCode, backgroundContext) {
if (shouldNotify(exitCode, backgroundContext)) {
enqueueSystemEvent({
type: "process-exit",
pid,
exitCode,
timestamp: Date.now(),
sessionId: backgroundContext.sessionId
});
requestHeartbeatNow();
}
}Why This Wasn’t Caught
- The heartbeat mechanism was designed primarily for internal timing, not user-facing notifications
- The
"none"default ensures silent operation for background heartbeat maintenance tasks - The coding-agent skill was added later without awareness of this delivery constraint
- No validation warning exists when skill uses heartbeat-triggering commands without proper config
๐ ๏ธ Step-by-Step Fix
Solution A: Configure heartbeat.target (Recommended for Users)
Step 1: Check Current Configuration
# View current heartbeat configuration
openclaw config get heartbeat
# Expected output (default):
# { "target": "none", "interval": 30000 }
# Or view specific target
openclaw config get heartbeat.target
# Expected output: noneStep 2: Update Configuration
For global config (~/.config/openclaw/config.yaml):
# Before (default)
# No heartbeat.target entry (or implicit "none")
# After
heartbeat:
target: "last"
interval: 30000Via CLI:
openclaw config set heartbeat.target last
# Output: Configuration updated successfully
# Verify
openclaw config get heartbeat.target
# Output: lastFor project config (openclaw.yaml in workspace):
# openclaw.yaml
# Before
version: "1"
# After
version: "1"
heartbeat:
target: "last"Step 3: Restart OpenClaw Daemon (if running)
# For Homebrew-installed OpenClaw
brew services restart openclaw
# For npm-installed
openclaw daemon stop
openclaw daemon startSolution B: Use Session Target (For Multi-User Setups)
If running in a multi-user or session-based environment:
# openclaw.yaml
version: "1"
heartbeat:
target: "session" # Delivers to originating session instead of last channel
interval: 30000Solution C: Modify Coding-Agent Skill (For Developers)
If you control the skill and want to avoid requiring user configuration:
# skills/coding-agent/SKILL.md
# Modify the Auto-Notify section from:
## Auto-Notify on Completion
When the agent finishes a background task, it will automatically notify via:
\`\`\`bash
openclaw system event --text "Done: {summary}" --mode now
\`\`\`
# To a mechanism that doesn't depend on heartbeat delivery:
## Auto-Notify on Completion
When the agent finishes a background task, it will automatically notify via
the message tool:
1. Use the built-in message tool to send directly to the current session
2. Format: `message(to="session", content="Done: {summary}")`
3. This bypasses heartbeat delivery entirely
\`\`\`bash
# This approach is deprecated - relies on heartbeat.target config
# openclaw system event --text "Done: {summary}" --mode now
\`\`\`Solution D: Add Startup Validation Warning (For Framework Maintainers)
Add a check in the skill loader to warn users when required config is missing:
// src/skills/skill-loader.ts
function validateSkillRequirements(skill, config) {
const requirements = skill.configRequirements || [];
for (const req of requirements) {
if (req.key === "heartbeat.target" && config.heartbeat?.target === "none") {
logger.warn(
`Skill "${skill.name}" requires heartbeat notifications but ` +
`heartbeat.target is set to "none". Add "heartbeat.target: last" to your config.`
);
}
}
}๐งช Verification
Test 1: Configuration Applied Correctly
# Verify config is set
openclaw config get heartbeat.target
# Expected: "last"
# Verify full heartbeat config
openclaw config get heartbeat
# Expected: { "target": "last", "interval": 30000 }Test 2: Manual System Event Delivery
# Enable debug logging (optional)
export DEBUG=openclaw:heartbeat
# Send a test system event
openclaw system event --text "Test notification" --mode now
# Expected debug output:
# [openclaw:heartbeat] Resolving delivery target for system event heartbeat
# [openclaw:heartbeat] target config: "last"
# [openclaw:heartbeat] Delivering to: LastChannelTarget { channelId: "..." }
# [openclaw:heartbeat] Response delivered successfully
# Expected visible output in terminal:
# Test notificationTest 3: Coding-Agent Background Task Notification
# Start a background task with coding-agent
openclaw exec --background -- coding-agent "sleep 2 && echo 'Analysis complete'"
# Get the task ID
TASK_ID=$(openclaw task list --json | jq -r '.[0].id')
# Wait for completion (with timeout)
timeout 30 bash -c 'while openclaw task get '$TASK_ID' --json | jq -e ".status != \"completed\"" > /dev/null; do sleep 1; done'
# Check if notification was delivered
openclaw task get $TASK_ID --json | jq '.notifications'
# Expected: [ { "type": "system-event", "delivered": true, ... } ]
# Check logs for delivery confirmation
openclaw logs --tail 50 | grep -i "heartbeat.*delivered"
# Expected: [openclaw:heartbeat] Response delivered successfullyTest 4: Integration Test Script
#!/bin/bash
# test-notification.sh - Run after applying fix
set -e
echo "=== Testing Heartbeat Notification Fix ==="
# Check config
TARGET=$(openclaw config get heartbeat.target)
if [ "$TARGET" != "last" ] && [ "$TARGET" != "session" ]; then
echo "FAIL: heartbeat.target is '$TARGET', expected 'last' or 'session'"
exit 1
fi
echo "PASS: heartbeat.target is '$TARGET'"
# Send test event
RESULT=$(openclaw system event --text "Test $(date +%s)" --mode now --json)
DELIVERED=$(echo "$RESULT" | jq -r '.delivered // .success // false')
if [ "$DELIVERED" = "true" ]; then
echo "PASS: Test notification delivered successfully"
else
echo "FAIL: Test notification was not delivered"
echo "Raw result: $RESULT"
exit 1
fi
echo "=== All tests passed ==="
exit 0Expected Exit Codes
| Test | Success Exit Code | Failure Exit Code |
|---|---|---|
| Config check | 0 | 1 |
| Manual event | 0 | 1 |
| Background task | 0 | 1 |
โ ๏ธ Common Pitfalls
Pitfall 1: Config File Location Priority
OpenClaw reads config from multiple locations. Wrong location causes changes to be ignored.
# Config priority (highest to lowest):
# 1. Project config: ./openclaw.yaml
# 2. User config: ~/.config/openclaw/config.yaml (Linux)
# ~/Library/Preferences/openclaw/config.yaml (macOS)
# 3. Environment: OPENCLAW_HEARTBEAT_TARGET=last
# 4. Default: "none"
# Verify which config is active
openclaw config show --source
# Output: /Users/you/.config/openclaw/config.yaml
# Check for conflicting project config
cat ./openclaw.yaml 2>/dev/null || echo "No project config"Pitfall 2: Docker/Container Environment Variable Override
In Docker deployments, environment variables may shadow config files.
# Wrong - env var may be set but config shows different
$ echo $OPENCLAW_HEARTBEAT_TARGET
# (empty)
$ openclaw config get heartbeat.target
# none
# The default is coming from compiled defaults, not explicit config
# Fix: Either set the env var or create a config fileDocker Compose Example:
# docker-compose.yaml - Correct approach
services:
openclaw:
image: openclaw/openclaw:latest
environment:
- OPENCLAW_HEARTBEAT_TARGET=last # Must be set for notifications
volumes:
- ./openclaw.yaml:/app/openclaw.yaml:ro # Or use config filePitfall 3: Daemon Not Restarted After Config Change
Config changes require daemon restart to take effect.
# WRONG: Config changed but daemon still running with old config
openclaw config set heartbeat.target last
openclaw system event --text "Test" --mode now # Still uses old config
# CORRECT: Restart daemon
openclaw config set heartbeat.target last
openclaw daemon restart
sleep 2
openclaw system event --text "Test" --mode now # Now uses new configPitfall 4: macOS Homebrew Service Not Restarted
# Homebrew-managed services require explicit restart
brew services restart openclaw
# Verify service status
brew services list | grep openclaw
# Expected: openclaw started ... /Users/.../Library/LaunchAgents/...
# Check actual running config
openclaw config show | grep -A2 heartbeatPitfall 5: Non-Interactive Session Delivery
When running in non-interactive mode, "last" may deliver to a different channel than expected.
# CI/CD environments or detached processes
# "last" target resolves to whatever channel was last active
# which may be stale or non-existent
# Solution: Use "session" target for predictable delivery
# Or ensure session context is explicitly passedPitfall 6: Conflicting Skills Overwriting Config
Some skills may programmatically set heartbeat.target to "none" for their own purposes.
# Check if any skill modifies heartbeat config
grep -r "heartbeat.target" skills/
# or
grep -r "config.set.*heartbeat" ~/.local/share/openclaw/skills/Pitfall 7: Version Mismatch
The "last" and "session" options were added in a specific version. Using an older version silently ignores the config.
# Check OpenClaw version
openclaw --version
# v0.14.x or earlier: "last"/"session" may not be available
# v0.15.x+: Full heartbeat target options supported
# Upgrade if needed
npm update -g openclaw
# or
brew upgrade openclaw๐ Related Errors
Connected Error Codes and Historical Issues
HEARTBEAT_NO_TARGETโ Internal error when heartbeat fires but no delivery target exists. Manifests as silent failure in default configurations.ENQUEUESYSTEM_EVENT_DROPPEDโ Occurs when system events are enqueued during daemon shutdown or when heartbeat system is disabled.BACKGROUND_EXEC_NOTIFY_FAILEDโ Related tomaybeNotifyOnExit()failing to deliver completion notifications. Shares the same root cause as this issue.SKILL_NOTIFICATION_TIMEOUTโ Agents waiting for completion notifications may time out ifheartbeat.targetis"none", causing skill execution to appear hung.- Issue #1847 โ "Background task notifications not working in v0.14.2" โ Original report of this class of issue.
- Issue #2103 โ "maybeNotifyOnExit silently fails when heartbeat.target is none" โ Confirmed upstream.
- Issue #2256 โ "Documentation does not mention heartbeat.target default value" โ Documentation gap tracking issue.
- Issue #2389 โ "Coding-agent skill unusable without manual config" โ Feature request to fix default behavior.
Related Configuration Options
# These related options may also affect notification behavior
heartbeat:
target: "last" # Required for notifications (the fix)
interval: 30000 # How often heartbeat fires passively
mode: "auto" # When "manual", only explicit requests fire
suppressOnIdle: true # May suppress notifications when no activity
system:
eventBufferSize: 100 # Events dropped if buffer full and daemon busyDocumentation References
docs/gateway/heartbeat.mdโ Documents heartbeat architecture but omits the"none"default impact on notificationsskills/coding-agent/SKILL.mdโ Referencesopenclaw system eventcommand without noting the config requirementdocs/config/reference.mdโ Listsheartbeat.targetoptions but doesn't explain notification implications