Codi-E mascot

Codi-E Blog

Debugging an Owner-Only Tool Policy in OpenClaw

Why cron kept saying “not found,” five wrong fixes, and the one line of config that actually worked.

· 12 min read

In the previous post, I deployed an OpenClaw agent on a Raspberry Pi 4 over SSH. The bot worked — Telegram messages in, Claude Sonnet responses out, 24/7 on a $50 computer. The “What Comes Next” section listed cron jobs as an obvious next step.

The cron module was running at the gateway level. The logs said cron: started. But the agent could not call the cron tool. Every attempt returned "Tool cron not found."

Two debugging sessions, five config changes, and one deep dive into minified JavaScript later, I found the answer: a single config key that nobody tells you about.

The Problem

Pi-E — our OpenClaw agent on the Raspberry Pi — was deployed and working. You could message it on Telegram, it would call Claude Sonnet, and you would get a response. File I/O, shell commands, web search — all functional.

But ask it to schedule a reminder or list cron jobs, and you got this:

Tool cron not found.

The confusing part: the gateway logs clearly showed the cron module was running:

cron: started {"enabled":true,"jobs":1,"nextWakeAtMs":1771653600000}
cron: timer armed {"nextAt":1771653600000,"delayMs":60000,"clamped":true}

The module runs at the gateway level and fires scheduled jobs on time. The tool is the interface the agent uses to create, list, and manage those jobs. The module was alive; the tool was invisible.

Even stranger: the canvas tool worked fine. Both canvas and cron are “gateway-backed tools” — they connect to the gateway over WebSocket rather than running in-process. If it were a gateway connectivity issue, canvas would break too. Something else was filtering out cron specifically.

What We Tried (and Why It Failed)

Here is every config change we attempted, in order. Each one seemed reasonable at the time. None of them worked.

Attempt 1: Remove group:automation from deny

The original config explicitly denied the group:automation tool group, which contains cron and gateway. This was intentional — when we first set up the Pi, we were being conservative about tool access.

// Original config (intentionally restrictive)
{
  "tools": {
    "profile": "coding",
    "deny": ["group:automation", "group:nodes", "sessions_spawn"]
  }
}

We removed group:automation from the deny list to allow cron. The cron module loaded at startup. But the agent still could not see the cron tool.

Attempt 2: Explicitly allow cron in the allowlist

{
  "tools": {
    "allow": ["cron", "gateway"]
  }
}

The gateway logged a warning:

tools.allow allowlist contains unknown entries (cron, gateway).
Ignoring allowlist so core tools remain available.
Use tools.alsoAllow for additive plugin tool enablement.

Why it failed: cron and gateway are gateway-backed tool names. They are loaded dynamically from the gateway’s WebSocket connection, not from the static tool registry. The allowlist validator does not know about them at config time, so it rejects them as “unknown” and throws out the entire allowlist.

Attempt 3: Use alsoAllow instead

Following the warning’s own suggestion:

{
  "tools": {
    "profile": "full",
    "deny": ["group:nodes", "sessions_spawn"],
    "alsoAllow": ["group:automation"]
  }
}

The gateway accepted this config without warnings. Clean reload, no errors. But the agent still reported "Tool cron not found."

Why it failed: The alsoAllow key controls the tool allowlist, which was never the problem. Something else was removing the tool before the allow/deny system ever saw it.

Attempt 4: Switch to profile: full

{
  "tools": {
    "profile": "full",
    "deny": ["sessions_spawn"],
    "exec": { "security": "full" }
  }
}

The full profile includes everything — no restrictions. Still no cron tool.

Why it failed: Same root cause. The profile controls which tool groups are enabled, but the filtering happened at a deeper layer that runs after profile resolution.

Attempt 5: Remove group:nodes from deny

A long-shot guess that denying group:nodes might somehow interfere with gateway-backed tools.

Why it failed: group:nodes contains only the nodes tool. It has no relationship to cron. This was grasping at straws.

The pattern: Five attempts, same result. The config-level tool access system — profiles, allow, deny, alsoAllow — was not the bottleneck. The cron tool was being filtered somewhere that config could not reach.

Reading the Source Code

When the config does not work, read the code.

OpenClaw is open-source, but the production build ships as minified JavaScript. The Docker image does not contain the original TypeScript sources. So I grepped the minified dist/ files inside the running container.

Finding the policy

# Search for "cron" in the helper modules
docker exec openclaw-gateway-1 \
  grep -rn 'cron' /app/dist/pi-embedded-helpers-*.js | head -20

# Search for anything "OWNER" related
docker exec openclaw-gateway-1 \
  grep -n 'OWNER_ONLY' /app/dist/pi-embedded-helpers-*.js

Output:

317:const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set([
345:  return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name));

There it was. I read the surrounding code:

const OWNER_ONLY_TOOL_NAME_FALLBACKS = new Set([
    "whatsapp_login",
    "cron",
    "gateway"
]);

function isOwnerOnlyToolName(name) {
    return OWNER_ONLY_TOOL_NAME_FALLBACKS.has(normalizeToolName(name));
}

function applyOwnerOnlyToolPolicy(tools, senderIsOwner) {
    const withGuard = tools.map((tool) => {
        if (!isOwnerOnlyTool(tool)) return tool;
        return wrapOwnerOnlyToolExecution(tool, senderIsOwner);
    });
    if (senderIsOwner) return withGuard;
    return withGuard.filter((tool) => !isOwnerOnlyTool(tool));
}

What the code does

Three tools are hardcoded as owner-only: whatsapp_login, cron, and gateway. When a message arrives, OpenClaw checks whether the sender is recognized as the “owner.” If not, the critical line executes:

return withGuard.filter((tool) => !isOwnerOnlyTool(tool));

Owner-only tools are completely removed from the tool list. The agent never sees them. It is not an error or a permission denial — the tools simply do not exist in the agent’s world.

This explains everything. The canvas tool worked because it is not in the owner-only set. The config-level allow/deny system was irrelevant because this filtering runs after it. The gateway logs showed the cron module running because the module does not care about ownership — only the tool interface does.

  Tool Resolution Stack (OpenClaw)

  ┌─────────────────────────────────────┐
  │  1. Profile resolution              │  "full" → all groups enabled
  │     (tools.profile)                 │
  ├─────────────────────────────────────┤
  │  2. Allow/Deny filtering            │  tools.deny, tools.allow,
  │     (config-level)                  │  tools.alsoAllow
  ├─────────────────────────────────────┤
  │  3. Gateway-backed tool injection   │  cron, gateway, canvas
  │     (via WebSocket)                 │  added to tool list
  ├─────────────────────────────────────┤
  │  4. Owner-only policy filtering     │  ← THE BLOCKER
  │     (applyOwnerOnlyToolPolicy)      │  cron, gateway removed
  │                                     │  if sender ≠ owner
  ├─────────────────────────────────────┤
  │  5. Final tool list → LLM          │  Agent sees 20 tools,
  │                                     │  not 23
  └─────────────────────────────────────┘

Finding the owner config

Next question: how does OpenClaw determine who the “owner” is? More grepping:

docker exec openclaw-gateway-1 \
  grep -rn 'senderIsOwner\|ownerAllowFrom' /app/dist/ --include='*.js' | head -10

This revealed:

const configOwnerAllowFromList = resolveOwnerAllowFromList({
    dock, cfg, accountId: ctx.AccountId,
    providerId,
    allowFrom: cfg.commands?.ownerAllowFrom
});

The config key is commands.ownerAllowFrom. It takes an array of sender IDs. If your Telegram user ID is in that array, you are the owner. If the key is missing, nobody is the owner, and cron and gateway are invisible to everyone.

The Fix

One config key:

{
  "commands": {
    "ownerAllowFrom": ["YOUR_TELEGRAM_USER_ID"]
  },
  "tools": {
    "profile": "full",
    "deny": ["sessions_spawn"],
    "exec": { "security": "full" }
  }
}

But changing the config is not enough. The tool policy is baked into the session state when a session is created. Existing sessions keep using the old tool list. You need to clear them:

# Clear all cached sessions
docker exec openclaw-gateway-1 bash -c \
  'echo "{}" > /home/node/.openclaw/agents/main/sessions/sessions.json'

# Full container recreate (not just restart)
docker compose down && docker compose up -d

Session caching gotcha: OpenClaw stores the tool list in each session’s state. If you change the config but do not clear sessions, the agent keeps using the old tool list. A config change + restart is not enough — you need to clear the session store too.

A related issue: gateway.bind

While debugging, we also hit a separate problem. If the Docker Compose file passes --bind lan as a CLI flag, the gateway resolves its WebSocket URL to the container’s Docker bridge IP:

SECURITY ERROR: Gateway URL "ws://172.19.0.2:18789" uses plaintext
ws:// to a non-loopback address.
Both credentials and chat data would be exposed to network interception.

Inside Docker, “LAN” means the container’s virtual network interface, not localhost. The cron tool connects to the gateway via WebSocket, and the security check rejects non-loopback connections over plaintext ws://.

The fix: remove --bind from docker-compose.yml entirely. The default is loopback, which is correct inside a container.

# Before (broken):
command: ["node", "dist/index.js", "gateway", "--bind", "lan", "--port", "18789"]

# After (working):
command: ["node", "dist/index.js", "gateway", "--port", "18789"]

With --bind lan, the gateway starts with a warning — "Config invalid; doctor will run with best-effort config" — which silently skips the cron module entirely. The Telegram provider works because it runs inside the gateway process and does not need a WebSocket connection. This is a silent failure: Telegram works, cron does not, and the error is buried in structured logs.

Verification

After applying the fix — ownerAllowFrom, session clear, container recreate, and --bind removed — we verified via the CLI:

docker exec openclaw-gateway-1 node dist/index.js agent \
  --agent main --message "List all your tools."

Output: 23 tools, including cron and gateway:

Available tools (23):
  agents_list    browser      canvas       cron
  edit           exec         gateway      image
  memory_get     memory_search  message    nodes
  process        read         session_status
  sessions_history  sessions_list  sessions_send
  subagents      tts          web_fetch    web_search
  write

We asked the agent to list cron jobs:

> Call the cron tool with action list.

Daily System Report (daily-report) — daily at 07:00 CET → Telegram.
Running fine.

The daily-report job was a cron entry created during setup. It had been firing on schedule the entire time — the gateway cron module does not need owner permissions. Only the agent-facing tool does.

One more layer: the value that got lost

The CLI verification worked, but Telegram still could not see the cron tool. More debugging revealed why: resolveCommandAuthorization correctly set senderIsOwner = true for our Telegram user, but by the time the value reached applyOwnerOnlyToolPolicy in sandbox-CtGHlB67.js, it had become false. The CLI agent hardcodes senderIsOwner: true and skips this pipeline entirely.

The fix: patch the tool policy function in the minified JavaScript inside the Docker image to force senderIsOwner = true. This is safe because the Telegram allowlist already restricts who can talk to the bot, and the network isolation layers prevent any lateral movement.

# Patch the owner-only tool policy to bypass the check
docker cp openclaw-gateway-1:/app/dist/sandbox-CtGHlB67.js /tmp/sandbox.js

# Add: senderIsOwner = true; at the start of applyOwnerOnlyToolPolicy
sed -i 's/function applyOwnerOnlyToolPolicy(tools, senderIsOwner) {/\
function applyOwnerOnlyToolPolicy(tools, senderIsOwner) { senderIsOwner = true;/' \
  /tmp/sandbox.js

docker cp /tmp/sandbox.js container:/app/dist/sandbox-CtGHlB67.js
docker commit openclaw-gateway-1 openclaw:patched

Not elegant. But it confirmed the root cause: somewhere in the pipeline between command.senderIsOwner and options?.senderIsOwner, the value is dropped. This is likely a bug in OpenClaw’s Telegram integration — the owner flag is resolved correctly but not threaded through to the embedded agent’s tool builder.

Lessons Learned

1. Config is not the whole story

OpenClaw has at least four layers of tool access control: profiles, allow/deny lists, gateway-backed tool injection, and owner-only policy filtering. The config controls the first two. The owner-only policy is a hardcoded set in the source that runs after everything else. No amount of config changes will override it.

2. Read the source when config does not work

After the second failed config attempt, we should have read the code. Grepping minified JavaScript is not pleasant, but it took ten minutes and gave us the definitive answer. The config kept saying “yes, cron is allowed.” The code kept saying “no, you are not the owner.”

3. Session state survives config changes

This one bit us twice. OpenClaw caches the tool list in session state. Changing the config and restarting the container is not enough — existing sessions keep the old tool list. You must clear sessions.json or do a full container recreate with session cleanup.

4. Silent failures are the hardest bugs

The --bind lan issue produced no visible error when you talked to the bot. Telegram worked perfectly. The cron module silently failed to load. The only hint was a structured log line about “best-effort config” that you would miss unless you knew to look for it.

Similarly, the owner-only policy does not log a warning. It does not tell you “cron was removed because you are not the owner.” It just removes the tool from the list. The agent genuinely does not know it exists.

5. Modules vs. tools are different things

The cron module runs at the gateway level and fires scheduled jobs. The cron tool is the interface the agent uses to manage jobs. The module can run while the tool is invisible. Seeing cron: started in the logs does not mean the agent can use it.

The takeaway: When a tool is missing from an AI agent’s capabilities, the problem might not be in the configuration at all. There can be hidden policy layers between your config and the agent’s actual tool list. When in doubt, read the code that resolves the tool list — not the code that parses the config.