Skill: chunhualiao/rotate-openrouter-key (version 1.0.0)

--- name: rotate-openrouter-key description: Safely rotate the OpenRouter API key across all config files in an OpenClaw installation. Finds every location where the key is stored, updates them, restarts the gateway, and verifies the new key works. Use when asked to "rotate openrouter key", "change openrouter api key", "update openrouter key", or "replace openrouter key". --- # Rotate OpenRouter Key Safely replace the OpenRouter API key across an entire OpenClaw installation, handling all config locations, priority chains, and verification. ## When to Use - User says "rotate my openrouter key" or "change openrouter key" - User reports 401 errors from OpenRouter - User disabled an old key and needs to set a new one - Periodic key rotation for security ## Key Priority Chain OpenClaw reads the OpenRouter API key from three sources, highest priority first: 1. **`~/.openclaw/.env`** — environment file, overrides everything 2. **`~/.openclaw/agents/<name>/agent/models.json`** — per-agent config 3. **`~/.openclaw/openclaw.json`** — global config If a higher-priority source has the old key, updating a lower-priority file has no effect. **You must update the key at whichever level it is actually being read from.** ## Workflow ### Step 1: Get the New Key Ask the user for the new key. It must start with `sk-or-v1-`. If the user doesn't have one yet, direct them to [openrouter.ai/keys](https://openrouter.ai/keys) to generate one. ### Step 2: Find All Key Locations ```bash # Find every file containing an OpenRouter key grep -r "sk-or-v1" ~/.openclaw/ --include="*.json" --include=".env" -l 2>/dev/null # Also check for uncommented key in .env grep -v '^#' ~/.openclaw/.env 2>/dev/null | grep OPENROUTER_API_KEY ``` Report what you found to the user before making changes. ### Step 3: Update All Locations and Verify Run the helper script — it handles both `.env` and JSON files in one pass: ```bash python3 scripts/update-openrouter-key.py --key "sk-or-v1-NEW-KEY" --verify ``` The script: - Finds all config files (`.env` + JSON) containing an openrouter key - Creates timestamped backups before each write - Updates only the key value (minimal change) - Verifies the new key against the OpenRouter API - Reports what it changed Preview first with `--dry-run`: ```bash python3 scripts/update-openrouter-key.py --key "sk-or-v1-NEW-KEY" --dry-run ``` ### Step 4: Restart Gateway ```bash openclaw gateway restart ``` ### Step 5: Remote Hosts (if applicable) If the user manages OpenClaw on other machines, repeat Steps 2-5 via SSH: ```bash ssh <host> "grep -r 'sk-or-v1' ~/.openclaw/ --include='*.json' --include='.env' -l" ``` Then run the update script remotely or copy it over. ### Step 6: Disable Old Key **Only after verifying the new key works everywhere**, tell the user they can now safely disable/delete the old key at [openrouter.ai/keys](https://openrouter.ai/keys). ## Scope & Boundaries **Handles:** Finding, updating, and verifying OpenRouter API keys in all OpenClaw config locations. **Does NOT handle:** Other provider keys (Anthropic, OpenAI). Key generation (user does that on openrouter.ai). Billing or usage issues. ## Error Handling | Error | Cause | Fix | |-------|-------|-----| | 401 after update | Missed a config location | Re-run Step 2 to find remaining old keys | | Key works in curl but not in bot | `.env` has old key overriding JSON | Check and update `.env` | | Gateway won't restart | Unrelated issue | `openclaw gateway stop && openclaw gateway start` | | Remote host still failing | Forgot to update remote configs | SSH in and repeat Steps 2-5 | ## Limitations - Cannot generate or revoke keys (user must do that on openrouter.ai) - Cannot update keys on remote hosts without SSH access - Does not handle keys stored outside `~/.openclaw/` (e.g., in systemd environment files)

path: CHANGELOG.md
content: # Changelog

## v1.0.0 — 2026-02-21

### Added
- Initial release
- Key discovery across .env, agent configs, and global config
- Priority chain awareness (`.env` > agent > global)
- Timestamped backups before any write
- Atomic JSON writes
- Key verification against OpenRouter API
- `--find`, `--dry-run`, `--verify`, `--json` modes
- Complete reference guide with corner cases and remote host instructions


path: README.md
content: # Rotate OpenRouter Key

Safely rotate the OpenRouter API key across an entire OpenClaw installation.

## The Problem

OpenClaw stores the OpenRouter API key in up to three locations with a priority chain (`.env` > agent config > global config). Missing any one during rotation causes silent auth failures that are hard to diagnose.

## What It Does

1. Scans all config files to find every location where the key is stored
2. Reports the priority chain so you know which source actually controls the key
3. Updates all locations with the new key (with timestamped backups)
4. Restarts the gateway
5. Verifies the new key works against the OpenRouter API

## Install

```bash
clawhub install rotate-openrouter-key
```

Or copy the skill directory into your OpenClaw workspace `skills/` folder.

## Usage

Ask your agent:
- "Rotate my openrouter key"
- "Change my openrouter api key"
- "I'm getting 401 errors from openrouter"

### Script (standalone)

```bash
# Find where keys are stored
python3 scripts/update-openrouter-key.py --find

# Preview changes
python3 scripts/update-openrouter-key.py --key "sk-or-v1-NEW-KEY" --dry-run

# Update all locations
python3 scripts/update-openrouter-key.py --key "sk-or-v1-NEW-KEY"

# Verify key is valid
python3 scripts/update-openrouter-key.py --key "sk-or-v1-NEW-KEY" --verify
```

## Requirements

- OpenClaw installation (`~/.openclaw/`)
- Python 3.6+
- Internet access (for key verification)

## Key Priority Chain

| Priority | Source | Path |
|----------|--------|------|
| 1 (highest) | Environment / .env | `~/.openclaw/.env` |
| 2 | Agent config | `~/.openclaw/agents/<name>/agent/models.json` |
| 3 (lowest) | Global config | `~/.openclaw/openclaw.json` |

The highest-priority source wins. The skill updates all locations to prevent silent override issues.

## Reference

See `references/key-rotation-guide.md` for the complete guide covering corner cases, remote hosts, and troubleshooting.

## Future Work

- Support rotating keys for other providers (Anthropic, OpenAI)
- Auto-detect remote OpenClaw hosts and update via SSH


path: references/key-rotation-guide.md
content: # Changing OpenRouter API Key for an Existing OpenClaw Installation

When you rotate, replace, or update your OpenRouter API key, there are multiple places it can live in an OpenClaw installation. Missing any one of them causes silent failover failures or 401 errors that are hard to diagnose.

## Where the Key Lives

OpenClaw can read the OpenRouter API key from **three sources**, in this priority order:

| Priority | Source | Path | Scope |
|----------|--------|------|-------|
| 1 (highest) | **Environment / .env** | `~/.openclaw/.env` | Overrides everything |
| 2 | **Agent config** | `~/.openclaw/agents/<name>/agent/models.json` | Per-agent override |
| 3 (lowest) | **Global config** | `~/.openclaw/openclaw.json` | Shared across all agents |

**The highest-priority source wins.** If `.env` has an active `OPENROUTER_API_KEY`, it overrides both JSON files. If not set in `.env`, the agent config wins over the global config. You must update the key at whichever level it is actually being read from -- updating a lower-priority file has no effect if a higher-priority one still has the old key.

### How to Find All Key Locations

```bash
# Find every file containing an OpenRouter key
grep -r "sk-or-v1" ~/.openclaw/ --include="*.json" -l
```

This will show exactly which files need updating. Common results:

```
~/.openclaw/openclaw.json
~/.openclaw/agents/main/agent/models.json
```

Multi-agent setups may have additional agent directories:

```
~/.openclaw/agents/research/agent/models.json
~/.openclaw/agents/writer/agent/models.json
```

**Every file listed must be updated.**

## Step-by-Step: Replace the Key

### 1. Get Your New Key

Generate a new key at [openrouter.ai/keys](https://openrouter.ai/keys). Copy it. It starts with `sk-or-v1-...`.

### 2. Find All Config Files with the Old Key

```bash
grep -r "sk-or-v1" ~/.openclaw/ --include="*.json" -l
```

### 3. Update Each File

For each file found, update only the `apiKey` field under the openrouter provider:

```bash
# Update global config
python3 -c "
import json, os
p = os.path.expanduser('~/.openclaw/openclaw.json')
c = json.load(open(p))
c['models']['providers']['openrouter']['apiKey'] = 'sk-or-v1-YOUR-NEW-KEY'
json.dump(c, open(p, 'w'), indent=2)
print('Updated:', p)
"

# Update agent config (repeat for each agent)
python3 -c "
import json, os
p = os.path.expanduser('~/.openclaw/agents/main/agent/models.json')
c = json.load(open(p))
c['providers']['openrouter']['apiKey'] = 'sk-or-v1-YOUR-NEW-KEY'
json.dump(c, open(p, 'w'), indent=2)
print('Updated:', p)
"
```

> **Do not copy entire files between installations.** Each machine may have different models, agents, or provider configs. Only change the `apiKey` value.

### 4. Restart the Gateway

```bash
openclaw gateway restart
```

### 5. Verify the Key Works

```bash
# Quick API test
curl -s https://openrouter.ai/api/v1/auth/key \
  -H "[REDACTED_SECRET]" | python3 -m json.tool
```

Expected output includes `"is_free_tier": false` and your usage stats. If you see `"error"`, the key is invalid.

## Corner Cases

### Multiple Agents with Different Keys

Some setups use different OpenRouter keys per agent (e.g., separate billing). Each agent's `models.json` has its own `apiKey`. Update each one individually:

```bash
# List all agent configs
find ~/.openclaw/agents/*/agent/models.json -exec grep -l "openrouter" {} \;
```

### Key in Global Config but Not Agent Config

If the agent config has no `apiKey` for openrouter, it inherits from `openclaw.json`. In this case, you only need to update the global config. But verify:

```bash
python3 -c "
import json, os
p = os.path.expanduser('~/.openclaw/agents/main/agent/models.json')
c = json.load(open(p))
key = c.get('providers', {}).get('openrouter', {}).get('apiKey', 'NOT SET')
print('Agent key:', key[:12] + '...' if len(key) > 12 else key)
"
```

### Remote Machines (SSH)

If you manage OpenClaw on remote hosts (e.g., a Mac Mini, VPS), you must update keys there too:

```bash
# Find and update on remote host
ssh myhost "grep -r 'sk-or-v1' ~/.openclaw/ --include='*.json' -l"

# Update remote agent config
ssh myhost "python3 -c \"
import json, os
p = os.path.expanduser('~/.openclaw/agents/main/agent/models.json')
c = json.load(open(p))
c['providers']['openrouter']['apiKey'] = 'sk-or-v1-NEW-KEY'
json.dump(c, open(p, 'w'), indent=2)
\"" 

# Restart remote gateway
ssh myhost "openclaw gateway restart"
```

### Old Key Still Cached After Restart

OpenClaw supports hot-reload for some config changes. If the key doesn't take effect after `openclaw gateway restart`:

```bash
# Hard restart: stop then start
openclaw gateway stop
openclaw gateway start
```

If still failing, check if the gateway is supervised by launchd/systemd and respawning with stale config:

```bash
# macOS
launchctl bootout gui/$(id -u)/ai.openclaw.gateway
openclaw gateway start

# Linux (systemd)
systemctl --user restart openclaw-gateway
```

### Disabled or Expired Key

If you disable an old key before updating the config, all OpenRouter models will fail immediately. The failover cascade looks like this in the logs:

```
HTTP 401: User not found.
No available auth profile for openrouter (all in cooldown or unavailable)
```

**Always update the config first, then disable the old key.** The safe order:

1. Generate new key on openrouter.ai
2. Update all config files with the new key
3. Restart gateway
4. Verify new key works (test a message)
5. **Then** disable/delete the old key

### Environment Variable Override

If `OPENROUTER_API_KEY` is set in the shell environment, some tools (scripts, CLI) may use that instead of the config file. Check:

```bash
echo $OPENROUTER_API_KEY
```

If set, update it too:

```bash
# In your shell profile (~/.bashrc, ~/.zshrc)
export OPENROUTER_API_KEY="sk-or-v1-YOUR-NEW-KEY"
```

### Key in .env Files (Common Override Source)

OpenClaw loads `~/.openclaw/.env` at startup. If `OPENROUTER_API_KEY` is set there (even if it's also in the JSON configs), **the `.env` value takes priority**. This is the most common cause of "I updated the JSON but it still uses the old key."

```bash
# Check if .env has an active (uncommented) key
grep -v '^#' ~/.openclaw/.env 2>/dev/null | grep OPENROUTER

# If it shows a key, update it
sed -i 's|^OPENROUTER_API_KEY=.*|OPENROUTER_API_KEY=sk-or-v1-YOUR-NEW-KEY|' ~/.openclaw/.env

# Or if you prefer JSON config only, comment it out
sed -i 's|^OPENROUTER_API_KEY=|#OPENROUTER_API_KEY=|' ~/.openclaw/.env
```

Also check for `.env` files in workspace directories:

```bash
find ~/.openclaw/ -name ".env" -exec grep -l "OPENROUTER" {} \;
```

### Verifying After Key Change

After updating and restarting, send a test message through each channel (Telegram, Discord, etc.) to confirm the bot responds. Check logs for errors:

```bash
# macOS
tail -f /tmp/openclaw/openclaw-$(date +%Y-%m-%d).log | grep -i "error\|401\|auth"

# Linux
journalctl --user -u openclaw-gateway -f | grep -i "error\|401\|auth"
```

## Quick Reference Checklist

- [ ] Generated new key on openrouter.ai/keys
- [ ] Found all config files: `grep -r "sk-or-v1" ~/.openclaw/ --include="*.json" -l`
- [ ] Updated `~/.openclaw/openclaw.json`
- [ ] Updated each `~/.openclaw/agents/*/agent/models.json`
- [ ] Updated remote hosts (if any)
- [ ] Updated `OPENROUTER_API_KEY` env var (if set)
- [ ] Updated `.env` files (if any)
- [ ] Restarted gateway: `openclaw gateway restart`
- [ ] Verified key works: `curl openrouter.ai/api/v1/auth/key`
- [ ] Tested a message through the bot
- [ ] Disabled old key on openrouter.ai (only after verifying new key works)


path: scripts/update-openrouter-key.py
content: #!/usr/bin/env python3
"""
Update or verify the OpenRouter API key in all OpenClaw config files.

Usage:
  python3 update-openrouter-key.py --key "sk-or-v1-NEW-KEY"
  python3 update-openrouter-key.py --key "sk-or-v1-NEW-KEY" --verify
  python3 update-openrouter-key.py --key "sk-or-v1-NEW-KEY" --dry-run
  python3 update-openrouter-key.py --find   # just list where keys are
"""

import argparse
import json
import os
import re
import shutil
import sys
import urllib.request
import urllib.error
from datetime import datetime
from pathlib import Path

OPENCLAW_DIR = os.environ.get("OPENCLAW_DIR", os.path.expanduser("~/.openclaw"))


def find_json_files_with_key(base_dir: str) -> list:
    """Find all JSON files under base_dir containing an openrouter apiKey."""
    results = []
    for root, dirs, files in os.walk(base_dir):
        # Skip node_modules, .git, etc.
        dirs[:] = [d for d in dirs if d not in (".git", "node_modules", "__pycache__")]
        for f in files:
            if not f.endswith(".json"):
                continue
            path = os.path.join(root, f)
            try:
                with open(path) as fh:
                    content = fh.read()
                if "openrouter" not in content:
                    continue
                data = json.loads(content)
                # Check nested structures for openrouter apiKey
                for root_key in [data, data.get("models", {})]:
                    if not isinstance(root_key, dict):
                        continue
                    or_cfg = root_key.get("providers", {}).get("openrouter", {})
                    if or_cfg.get("apiKey"):
                        k = or_cfg["apiKey"]
                        results.append({
                            "path": path,
                            "current_key": k,
                            "key_preview": k[:8] + "..." + k[-4:] if len(k) > 12 else "***",
                        })
                        break
            except (OSError, json.JSONDecodeError):
                continue
    return results


def find_env_key(base_dir: str):
    """Check if .env has an active (uncommented) OPENROUTER_API_KEY."""
    env_path = os.path.join(base_dir, ".env")
    if not os.path.exists(env_path):
        return None
    try:
        with open(env_path) as f:
            for line in f:
                line = line.strip()
                if line.startswith("#") or not line:
                    continue
                if line.startswith("OPENROUTER_API_KEY="):
                    key = line.split("=", 1)[1].strip().strip('"').strip("'")
                    if key:
                        return {
                            "path": env_path,
                            "current_key": key,
                            "key_preview": key[:8] + "..." + key[-4:] if len(key) > 12 else "***",
                        }
    except OSError:
        pass
    return None


def backup_file(filepath: str) -> str:
    ts = datetime.now().strftime("%Y%m%d_%H%M%S")
    backup = f"{filepath}.bak.{ts}"
    shutil.copy2(filepath, backup)
    return backup


def update_json_key(filepath: str, new_key: str, dry_run: bool) -> bool:
    """Update openrouter apiKey in a JSON config file. Returns True if changed."""
    with open(filepath) as f:
        data = json.load(f)

    changed = False
    for root_key in [data, data.get("models", {})]:
        if not isinstance(root_key, dict):
            continue
        or_cfg = root_key.get("providers", {}).get("openrouter", {})
        if or_cfg.get("apiKey") and or_cfg["apiKey"] != new_key:
            or_cfg["apiKey"] = new_key
            changed = True

    if changed and not dry_run:
        backup_file(filepath)
        tmp = filepath + ".tmp"
        with open(tmp, "w") as f:
            json.dump(data, f, indent=2)
        os.replace(tmp, filepath)

    return changed


def update_env_key(filepath: str, new_key: str, dry_run: bool) -> bool:
    """Update OPENROUTER_API_KEY in a .env file. Returns True if changed."""
    with open(filepath) as f:
        lines = f.readlines()

    changed = False
    new_lines = []
    for line in lines:
        stripped = line.strip()
        if not stripped.startswith("#") and stripped.startswith("OPENROUTER_API_KEY="):
            old_val = stripped.split("=", 1)[1].strip().strip('"').strip("'")
            if old_val != new_key:
                new_lines.append(f"OPENROUTER_API_KEY={new_key}\n")
                changed = True
                continue
        new_lines.append(line)

    if changed and not dry_run:
        backup_file(filepath)
        with open(filepath, "w") as f:
            f.writelines(new_lines)

    return changed


def verify_key(key: str) -> dict:
    """Verify an OpenRouter key against the API. Returns auth info or error."""
    req = urllib.request.Request(
        "https://openrouter.ai/api/v1/auth/key",
        headers={"Authorization": f"Bearer {key}", "Accept": "application/json"},
    )
    try:
        with urllib.request.urlopen(req, timeout=15) as resp:
            data = json.loads(resp.read())
            info = data.get("data", {})
            return {
                "valid": True,
                "label": info.get("label", ""),
                "limit": info.get("limit"),
                "remaining": info.get("limit_remaining"),
                "is_free_tier": info.get("is_free_tier"),
                "expires_at": info.get("expires_at"),
            }
    except urllib.error.HTTPError as e:
        return {"valid": False, "error": f"HTTP {e.code}: {e.reason}"}
    except Exception as e:
        return {"valid": False, "error": str(e)}


def main():
    p = argparse.ArgumentParser(description="Update OpenRouter API key in OpenClaw")
    p.add_argument("--key", help="New OpenRouter API key")
    p.add_argument("--find", action="store_true", help="Just list where keys are stored")
    p.add_argument("--verify", action="store_true", help="Verify the key against OpenRouter API")
    p.add_argument("--dry-run", action="store_true", help="Show what would change without writing")
    p.add_argument("--json", action="store_true", help="Machine-readable output")
    args = p.parse_args()

    # Find all key locations
    json_files = find_json_files_with_key(OPENCLAW_DIR)
    env_entry = find_env_key(OPENCLAW_DIR)

    all_locations = []
    if env_entry:
        all_locations.append({"type": "env", **env_entry})
    for jf in json_files:
        all_locations.append({"type": "json", **jf})

    if args.find:
        if not all_locations:
            print("No OpenRouter keys found in", OPENCLAW_DIR, file=sys.stderr)
            sys.exit(1)
        print(f"Found {len(all_locations)} key location(s):", file=sys.stderr)
        for loc in all_locations:
            priority = "HIGH (overrides JSON)" if loc["type"] == "env" else "normal"
            print(f"  [{priority}] {loc['path']} — {loc['key_preview']}", file=sys.stderr)
        if args.json:
            print(json.dumps([{"path": l["path"], "type": l["type"]} for l in all_locations]))
        return

    if not args.key:
        print("ERROR: --key is required (or use --find to list locations).", file=sys.stderr)
        sys.exit(1)

    if not args.key.startswith("sk-or-"):
        print("WARNING: Key doesn't start with 'sk-or-'. Are you sure it's an OpenRouter key?", file=sys.stderr)

    # Verify key if requested
    if args.verify:
        print("Verifying key...", file=sys.stderr)
        result = verify_key(args.key)
        if result["valid"]:
            print(f"  VALID — label: {result['label']}, remaining: ${result['remaining']:.2f}, "
                  f"free_tier: {result['is_free_tier']}, expires: {result['expires_at']}", file=sys.stderr)
        else:
            print(f"  INVALID — {result['error']}", file=sys.stderr)
            sys.exit(1)
        if args.json:
            print(json.dumps(result))
        if not all_locations:
            return  # verify-only mode

    if not all_locations:
        print("No existing OpenRouter keys found to update.", file=sys.stderr)
        sys.exit(1)

    # Update each location
    tag = "[DRY RUN] " if args.dry_run else ""
    updated = []
    skipped = []

    for loc in all_locations:
        if loc["current_key"] == args.key:
            skipped.append(loc["path"])
            print(f"  SKIP {loc['path']} (already has new key)", file=sys.stderr)
            continue

        if loc["type"] == "env":
            changed = update_env_key(loc["path"], args.key, args.dry_run)
        else:
            changed = update_json_key(loc["path"], args.key, args.dry_run)

        if changed:
            updated.append(loc["path"])
            print(f"  {tag}UPDATED {loc['path']}", file=sys.stderr)
        else:
            skipped.append(loc["path"])

    print(f"\n{tag}Updated {len(updated)} file(s), skipped {len(skipped)}.", file=sys.stderr)

    # Auto-verify after update if --verify was passed
    if args.verify and updated and not args.dry_run:
        print("\nPost-update verification...", file=sys.stderr)
        result = verify_key(args.key)
        if result["valid"]:
            print(f"  VALID — label: {result['label']}, remaining: ${result['remaining']:.2f}", file=sys.stderr)
        else:
            print(f"  INVALID — {result['error']}", file=sys.stderr)
            print("WARNING: Key failed verification. Check the key and try again.", file=sys.stderr)

    if updated and not args.dry_run:
        print("\nRun: openclaw gateway restart", file=sys.stderr)

    if args.json:
        print(json.dumps({"updated": updated, "skipped": skipped, "dry_run": args.dry_run}))


if __name__ == "__main__":
    main()


path: skill.yml
content: ---
name: rotate-openrouter-key
display_name: "Rotate OpenRouter Key"
description: Safely rotate the OpenRouter API key across all config files in an OpenClaw installation. Finds every location, updates them, restarts the gateway, and verifies.
version: 1.0.0
triggers:
  - rotate openrouter key
  - change openrouter key
  - update openrouter api key
  - replace openrouter key
  - openrouter 401 error
  - fix openrouter authentication


path: tests/test-triggers.json
content: {
  "shouldTrigger": [
    "rotate my openrouter key",
    "change openrouter api key",
    "update my openrouter key",
    "replace the openrouter key",
    "I'm getting 401 errors from openrouter",
    "openrouter authentication failed",
    "fix openrouter key"
  ],
  "shouldNotTrigger": [
    "add openrouter models",
    "what models does openrouter have?",
    "how much does openrouter cost?",
    "change my anthropic key",
    "rotate my ssh keys",
    "set up openrouter provider"
  ]
}


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: suspicious | SkillSpector: suspicious (HIGH)