OpenClaw config schema fixes and migration updates
Cluster 4652 · hermes cli. Data as of
Nothing open on GitHub. Fix merged in #7887 on 11 Apr 2026. Check it covers your case.
The short answer
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #7887. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address bugs and fixes related to reading API keys and handling schema changes in the OpenClaw configuration and migration process. A feature request for context compressor token capping and model routing is also bundled in one report.
Open issues0
Open PRs0
Fixes on record
-
#7887 fix(migration): update OpenClaw migration for schema drift
files:
docs/migration/openclaw.md,hermes_cli/claw.py,hermes_cli/main.py,optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py,tests/hermes_cli/test_claw.py,website/docs/guides/migrate-from-openclaw.mdShow the diff
-
docs/migration/openclaw.md@@ -11,12 +11,14 @@ When you run `hermes setup` for the first time and Hermes detects `~/.openclaw`, ### 2. CLI Command (quick, scriptable) ```bash -hermes claw migrate # Full migration with confirmation prompt -hermes claw migrate --dry-run # Preview what would happen +hermes claw migrate # Preview then migrate (always shows preview first) +hermes claw migrate --dry-run # Preview only, no changes hermes claw migrate --preset user-data # Migrate without API keys/secrets hermes claw migrate --yes # Skip confirmation prompt ``` +The migration always shows a full preview of what will be imported before making any changes. You review the preview and confirm before anything is written. + **All options:** | Flag | Description | @@ -39,7 +41,7 @@ Ask the agent to run the migration for you: ``` The agent will use the `openclaw-migration` skill to: -1. Run a dry-run first to preview changes +1. Run a preview first to show what would change 2. Ask about conflict resolution (SOUL.md, skills, etc.) 3. Let you choose between `user-data` and `full` presets 4. Execute the migration with your choices @@ -58,16 +60,31 @@ The agent will use the `openclaw-migration` skill to: | Messaging settings | `~/.openclaw/config.yaml` (TELEGRAM_ALLOWED_USERS, MESSAGING_CWD) | `~/.hermes/.env` | | TTS assets | `~/.openclaw/workspace/tts/` | `~/.hermes/tts/` | +Workspace files are also checked at `workspace.default/` and `workspace-main/` as fallback paths (OpenClaw renamed `workspace/` to `workspace-main/` in recent versions). + ### `full` preset (adds to `user-data`) | Item | Source | Destination | |------|--------|-------------| -| Telegram bot token | `~/.openclaw/config.yaml` | `~/.hermes/.env` | -| OpenRouter API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | -| OpenAI API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | -| Anthropic API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | -| ElevenLabs API key | `~/.openclaw/.env` or config | `~/.hermes/.env` | +| Telegram bot token | `openclaw.json` channels config | `~/.hermes/.env` | +| OpenRouter API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` | +| OpenAI API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` | +| Anthropic API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` | +| ElevenLabs API key | `.env`, `openclaw.json`, or `openclaw.json["env"]` | `~/.hermes/.env` | + +API keys are searched across four sources: inline config values, `~/.openclaw/.env`, the `openclaw.json` `"env"` sub-object, and per-agent auth profiles. + +Only allowlisted secrets are ever imported. Other credentials are skipped and reported. + +## OpenClaw Schema Compatibility -Only these 6 allowlisted secrets are ever imported. Other credentials are skipped and reported. +The migration handles both old and current OpenClaw config layouts: + +- **Channel tokens**: Reads from flat paths (`channels.telegram.botToken`) and the newer `accounts.default` layout (`channels.telegram.accounts.default.botToken`) +- **TTS provider**: OpenClaw renamed "edge" to "microsoft" — both are recognized and mapped to Hermes' "edge" +- **Provider API types**: Both short (`openai`, `anthropic`) and hyphenated (`openai-completions`, `anthropic-messages`, `google-generative-ai`) values are mapped correctly +- **thinkingDefault**: All enum values are handled including newer ones (`minimal`, `xhigh`, `adaptive`) +- **Matrix**: Uses `accessToken` field (not `botToken`) +- **SecretRef formats**: Plain strings, env templates (`${VAR}`), and `source: "env"` SecretRefs are resolved. `source: "file"` and `source: "exec"` SecretRefs produce a warning — add those keys manually after migration. ## Conflict Handling @@ -84,18 +101,24 @@ For skills, you can also use `--skill-conflict rename` to import conflicting ski ## Migration Report -Every migration (including dry runs) produces a report showing: +Every migration produces a report showing: - **Migrated items** — what was successfully imported - **Conflicts** — items skipped because they already exist - **Skipped items** — items not found in the source - **Errors** — items that failed to import -For execute runs, the full report is saved to `~/.hermes/migration/openclaw/<timestamp>/`. +For executed migrations, the full report is saved to `~/.hermes/migration/openclaw/<timestamp>/`. + +## Post-Migration Notes + +- **Skills require a new session** — imported skills take effect after restarting your agent or starting a new chat. +- **WhatsApp requires re-pairing** — WhatsApp uses QR-code pairing, not token-based auth. Run `hermes whatsapp` to pair. +- **Archive cleanup** — after migration, you'll be offered to rename `~/.openclaw/` to `.openclaw.pre-migration/` to prevent state confusion. You can also run `hermes claw cleanup` later. ## Troubleshooting ### "OpenClaw directory not found" -The migration looks for `~/.openclaw` by default. If your OpenClaw is installed elsewhere, use `--source`: +The migration looks for `~/.openclaw` by default, then tries `~/.clawdbot` and `~/.moldbot`. If your OpenClaw is installed elsewhere, use `--source`: ```bash hermes claw migrate --source /path/to/.openclaw ``` @@ -108,3 +131,12 @@ hermes skills install openclaw-migration ### Memory overflow If your OpenClaw MEMORY.md or USER.md exceeds Hermes' character limits, excess entries are exported to an overflow file in the migration report directory. You can manually review and add the most important ones. + +### API keys not found +Keys might be stored in different places depending on your OpenClaw setup: +- `~/.openclaw/.env` file +- Inline in `openclaw.json` under `models.providers.*.apiKey` +- In `openclaw.json` under the `"env"` or `"env.vars"` sub-objects +- In `~/.openclaw/agents/main/agent/auth-profiles.json` + +The migration checks all four. If keys use `source: "file"` or `source: "exec"` SecretRefs, they can't be resolved automatically — add them via `hermes config set`. -
hermes_cli/claw.py@@ -1,8 +1,9 @@ """hermes claw — OpenClaw migration commands. Usage: - hermes claw migrate # Interactive migration from ~/.openclaw - hermes claw migrate --dry-run # Preview what would be migrated + hermes claw migrate # Preview then migrate (always shows preview first) + hermes claw migrate --dry-run # Preview only, no changes + hermes claw migrate --yes # Skip confirmation prompt hermes claw migrate --preset full --overwrite # Full migration, overwrite conflicts hermes claw cleanup # Archive leftover OpenClaw directories hermes claw cleanup --dry-run # Preview what would be archived @@ -237,12 +238,12 @@ def _cmd_migrate(args): # Show what we're doing hermes_home = get_hermes_home() + auto_yes = getattr(args, "yes", False) print() print_header("Migration Settings") print_info(f"Source: {source_dir}") print_info(f"Target: {hermes_home}") print_info(f"Preset: {preset}") - print_info(f"Mode: {'dry run (preview only)' if dry_run else 'execute'}") print_info(f"Overwrite: {'yes' if overwrite else 'no (skip conflicts)'}") print_info(f"Secrets: {'yes (allowlisted only)' if migrate_secrets else 'no'}") if skill_conflict != "skip": @@ -251,31 +252,81 @@ def _cmd_migrate(args): print_info(f"Workspace: {workspace_target}") print() - # For execute mode (non-dry-run), confirm unless --yes was passed - if not dry_run and not getattr(args, "yes", False): - if not prompt_yes_no("Proceed with migration?", default=True): - print_info("Migration cancelled.") - return - # Ensure config.yaml exists before migration tries to read it config_path = get_config_path() if not config_path.exists(): save_config(load_config()) - # Load and run the migration + # Load the migration module try: mod = _load_migration_module(script_path) if mod is None: print_error("Could not load migration script.") return + except Exception as e: + print() + print_error(f"Could not load migration script: {e}") + logger.debug("OpenClaw migration error", exc_info=True) + return + + selected = mod.resolve_selected_options(None, None, preset=preset) + ws_target = Path(workspace_target).resolve() if workspace_target else None + + # ── Phase 1: Always preview first ────────────────────────── + try: + preview = mod.Migrator( + source_root=source_dir.resolve(), + target_root=hermes_home.resolve(), + execute=False, + workspace_target=ws_target, + overwrite=overwrite, + migrate_secrets=[redacted:secret-assignment] + output_dir=None, + selected_options=selected, + preset_name=preset, + skill_conflict_mode=skill_conflict, + ) + preview_report = preview.migrate() + except Exception as e: + print() + print_error(f"Migration preview failed: {e}") + logger.debug("OpenClaw migration preview error", exc_info=True) + return - selected = mod.resolve_selected_options(None, None, preset=preset) - ws_target = Path(workspace_target).resolve() if workspace_target else None + preview_summary = preview_report.get("summary", {}) + preview_count = preview_summary.get("migrated", 0) + if preview_count == 0: + print() + print_info("Nothing to migrate from OpenClaw.") + _print_migration_report(preview_report, dry_run=True) + return + + print() + print_header(f"Migration Preview — {preview_count} item(s) would be imported") + print_info("No changes have been made yet. Review the list below:") + _print_migration_report(preview_report, dry_run=True) + + # If --dry-run, stop here + if dry_run: + return + + # ── Phase 2: Confirm and execute ─────────────────────────── + print() + if not auto_yes: + if not sys.stdin.isatty(): + print_info("Non-interactive session — preview only.") + print_info("To execute, re-run with: hermes claw migrate --yes") + return + if not prompt_yes_no("Proceed with migration?", default=True): + print_info("Migration cancelled.") + return + + try: migrator = mod.Migrator( source_root=source_dir.resolve(), target_root=hermes_home.resolve(), - execute=not dry_run, + execute=True, workspace_target=ws_target, overwrite=overwrite, migrate_secrets=[redacted:secret-assignment] @@ -292,11 +343,11 @@ def _cmd_migrate(args): return # Print results - _print_migration_report(report, dry_run) + _print_migration_report(report, dry_run=False) - # After successful non-dry-run migration, offer to archive the source directory - if not dry_run and report.get("summary", {}).get("migrated", 0) > 0: - _offer_source_archival(source_dir, getattr(args, "yes", False)) + # After successful migration, offer to archive the source directory + if report.get("summary", {}).get("migrated", 0) > 0: + _offer_source_archival(source_dir, auto_yes) def _offer_source_archival(source_dir: Path, auto_yes: bool = False): @@ -330,6 +381,11 @@ def _offer_source_archival(source_dir: Path, auto_yes: bool = False): print_info("You can always rename it back if needed.") print() + if not auto_yes and not sys.stdin.isatty(): + print_info("Non-interactive session — skipping archival.") + print_info("Run later with: hermes claw cleanup") + return + if auto_yes or prompt_yes_no(f"Archive {source_dir} now?", default=True): try: archive_path = _archive_directory(source_dir) @@ -433,6 +489,9 @@ def _cmd_cleanup(args): if dry_run: archive_path = _archive_directory(source_dir, dry_run=True) print_info(f"Would archive: {source_dir} → {archive_path}") + elif not auto_yes and not sys.stdin.isatty(): + print_info(f"Non-interactive session — would archive: {source_dir}") + print_info("To execute, re-run with: hermes claw cleanup --yes") else: if auto_yes or prompt_yes_no(f"Archive {source_dir}?", default=True): try: -
hermes_cli/main.py@@ -5411,7 +5411,8 @@ def cmd_insights(args): claw_migrate = claw_subparsers.add_parser( "migrate", help="Migrate from OpenClaw to Hermes", - description="Import settings, memories, skills, and API keys from an OpenClaw installation" + description="Import settings, memories, skills, and API keys from an OpenClaw installation. " + "Always shows a preview before making changes." ) claw_migrate.add_argument( "--source", @@ -5420,7 +5421,7 @@ def cmd_insights(args): claw_migrate.add_argument( "--dry-run", action="store_true", - help="Preview what would be migrated without making changes" + help="Preview only — stop after showing what would be migrated" ) claw_migrate.add_argument( "--preset", -
optional-skills/migration/openclaw-migration/scripts/openclaw_to_hermes.py@@ -617,6 +617,19 @@ def source_candidate(self, *relative_paths: str) -> Optional[Path]: candidate = self.source_root / rel if candidate.exists(): return candidate + # OpenClaw renamed workspace/ to workspace-main/ (and workspace-{agentId} + # for multi-agent). Try the new path as a fallback. + if rel.startswith("workspace/"): + suffix = rel[len("workspace/"):] + for variant in ("workspace-main", "workspace-assistant"): + alt = self.source_root / variant / suffix + if alt.exists(): + return alt + elif rel.startswith("workspace.default/"): + suffix = rel[len("workspace.default/"):] + alt = self.source_root / "workspace-main" / suffix + if alt.exists(): + return alt return None def resolve_skill_destination(self, destination: Path) -> Path: @@ -1033,11 +1046,8 @@ def handle_secret_settings(self, config: Optional[Dict[str, Any]] = None) -> Non def migrate_secret_settings(self, config: Dict[str, Any]) -> None: secret_additions: [redacted:secret-assignment] str] = {} - telegram_token = ( - config.get("channels", {}) - .get("telegram", {}) - .get("botToken") - ) + tg_cfg = config.get("channels", {}).get("telegram", {}) + telegram_token = [redacted:secret-assignment] "botToken") if isinstance(tg_cfg, dict) else None if isinstance(telegram_token, str) and telegram_token.strip(): secret_additions["TELEGRAM_BOT_TOKEN"] = telegram_token.strip() @@ -1057,15 +1067,28 @@ def _resolve_channel_secret(self, value: Any) -> Optional[str]: """Resolve a channel config value that may be a SecretRef.""" return resolve_secret_input(value, self.load_openclaw_env()) + @staticmethod + def _get_channel_field(ch_cfg: Dict[str, Any], field: str) -> Any: + """Get a field from channel config, checking both flat and accounts.default layout.""" + val = ch_cfg.get(field) + if val is not None: + return val + accounts = ch_cfg.get("accounts") + if isinstance(accounts, dict): + default = accounts.get("default") + if isinstance(default, dict): + return default.get(field) + return None + def migrate_discord_settings(self, config: Optional[Dict[str, Any]] = None) -> None: config = config or self.load_openclaw_config() additions: Dict[str, str] = {} discord = config.get("channels", {}).get("discord", {}) if isinstance(discord, dict): - token = discord.get("token") + token = [redacted:secret-assignment] "token") if isinstance(token, str) and token.strip(): additions["DISCORD_BOT_TOKEN"] = token.strip() - allow_from = discord.get("allowFrom", []) + allow_from = self._get_channel_field(discord, "allowFrom") or [] if isinstance(allow_from, list): users = [str(u).strip() for u in allow_from if str(u).strip()] if users: @@ -1080,13 +1103,13 @@ def migrate_slack_settings(self, config: Optional[Dict[str, Any]] = None) -> Non additions: Dict[str, str] = {} slack = config.get("channels", {}).get("slack", {}) if isinstance(slack, dict): - bot_token = slack.get("botToken") + bot_token = [redacted:secret-assignment] "botToken") if isinstance(bot_token, str) and bot_token.strip(): additions["SLACK_BOT_TOKEN"] = bot_token.strip() - app_token = slack.get("appToken") + app_token = [redacted:secret-assignment] "appToken") if isinstance(app_token, str) and app_token.strip(): additions["SLACK_APP_TOKEN"] = app_token.strip() - allow_from = slack.get("allowFrom", []) + allow_from = self._get_channel_field(slack, "allowFrom") or [] if isinstance(allow_from, list): users = [str(u).strip() for u in allow_from if str(u).strip()] if users: @@ -1101,7 +1124,7 @@ def migrate_whatsapp_settings(self, config: Optional[Dict[str, Any]] = None) -> additions: Dict[str, str] = {} whatsapp = config.get("channels", {}).get("whatsapp", {}) if isinstance(whatsapp, dict): - allow_from = whatsapp.get("allowFrom", []) + allow_from = self._get_channel_field(whatsapp, "allowFrom") or [] if isinstance(allow_from, list): users = [str(u).strip() for u in allow_from if str(u).strip()] if users: @@ -1116,13 +1139,13 @@ def migrate_signal_settings(self, config: Optional[Dict[str, Any]] = None) -> No additions: Dict[str, str] = {} signal = config.get("channels", {}).get("signal", {}) if isinstance(signal, dict): - account = signal.get("account") + account = self._get_channel_field(signal, "account") if isinstance(account, str) and account.strip(): additions["SIGNAL_ACCOUNT"] = account.strip() - http_url = signal.get("httpUrl") + http_url = self._get_channel_field(signal, "httpUrl") if isinstance(http_url, str) and http_url.strip(): additions["SIGNAL_HTTP_URL"] = http_url.strip() - allow_from = signal.get("allowFrom", []) + allow_from = self._get_channel_field(signal, "allowFrom") or [] if isinstance(allow_from, list): users = [str(u).strip() for u in allow_from if str(u).strip()] if users: @@ -1161,6 +1184,16 @@ def migrate_provider_keys(self, config: Dict[str, Any]) -> None: raw_key = provider_cfg.get("apiKey") api_key = [redacted:secret-assignment] openclaw_env) if not api_key: + # Warn if a SecretRef with file/exec source was silently unresolvable + if isinstance(raw_key, dict) and raw_key.get("source") in ("file", "exec"): + self.record( + "provider-keys", + self.source_root / "openclaw.json", + None, + "skipped", + f"Provider '{provider_name}' uses a {raw_key['source']}-backed SecretRef " + f"that cannot be auto-migrated. Add this key manually via: hermes config set", + ) continue base_url = provider_cfg.get("baseUrl", "") @@ -1224,6 +1257,21 @@ def migrate_provider_keys(self, config: Dict[str, Any]) -> None: if val and hermes_key not in secret_additions: [redacted:secret-assignment] = val + # Check the openclaw.json "env" sub-object — some OpenClaw setups + # store API keys here instead of in a separate .env file. + # Keys can be at env.<KEY> or env.vars.<KEY>. + json_env = config.get("env") + if isinstance(json_env, dict): + env_vars = json_env.get("vars") + sources = [json_env] + if isinstance(env_vars, dict): + sources.append(env_vars) + for src in sources: + for oc_key, hermes_key in env_key_mapping.items(): + val = src.get(oc_key) + if isinstance(val, str) and val.strip() and hermes_key not in secret_additions: + secret_additions[hermes_key] = val.strip() + # Check per-agent auth-profiles.json for additional credentials auth_profiles_path = self.source_root / "agents" / "main" / "agent" / "auth-profiles.json" if auth_profiles_path.exists(): @@ -1324,8 +1372,9 @@ def migrate_tts_config(self, config: Optional[Dict[str, Any]] = None) -> None: tts_data: Dict[str, Any] = {} provider = tts.get("provider") - if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge"): - tts_data["provider"] = provider + if isinstance(provider, str) and provider in ("elevenlabs", "openai", "edge", "microsoft"): + # OpenClaw renamed "edge" to "microsoft"; Hermes still uses "edge" + tts_data["provider"] = "edge" if provider == "microsoft" else provider # TTS provider settings live under messages.tts.providers.{provider} # in OpenClaw (not messages.tts.elevenlabs directly) @@ -1374,9 +1423,9 @@ def migrate_tts_config(self, config: Optional[Dict[str, Any]] = None) -> None: tts_data["openai"] = oai_settings edge_tts = ( - (providers.get("edge") or {}) - if isinstance(providers.get("edge"), dict) else - (tts.get("edge") or {}) + (providers.get("edge") or providers.get("microsoft") or {}) + if isinstance(providers.get("edge"), dict) or isinstance(providers.get("microsoft"), dict) else + (tts.get("edge") or tts.get("microsoft") or {}) ) if isinstance(edge_tts, dict): edge_voice = edge_tts.get("voice") @@ -1890,11 +1939,11 @@ def migrate_agent_config(self, config: Optional[Dict[str, Any]] = None) -> None: if defaults.get("thinkingDefault"): # Map OpenClaw thinking -> Hermes reasoning_effort thinking = defaults["thinkingDefault"] - if thinking in ("always", "high"): + if thinking in ("always", "high", "xhigh"): agent_cfg["reasoning_effort"] = "high" - elif thinking in ("auto", "medium"): + elif thinking in ("auto", "medium", "adaptive"): agent_cfg["reasoning_effort"] = "medium" - elif thinking in ("off", "low", "none"): + elif thinking in ("off", "low", "none", "minimal"): agent_cfg["reasoning_effort"] = "low" changes = True @@ -2099,10 +2148,14 @@ def migrate_full_providers(self, config: Optional[Dict[str, Any]] = None) -> Non f"Provider '{prov_name}' already exists") continue - api_type = prov_cfg.get("apiType") or prov_cfg.get("type") or "openai" + api_type = prov_cfg.get("apiType") or prov_cfg.get("api") or prov_cfg.get("type") or "openai" api_mode_map = { "openai": "chat_completions", + "openai-completions": "chat_completions", + "openai-responses": "chat_completions", "anthropic": "anthropic_messages", + "anthropic-messages": "anthropic_messages", + "google-generative-ai": "chat_completions", "cohere": "chat_completions", } entry = { @@ -2142,7 +2195,7 @@ def migrate_deep_channels(self, config: Optional[Dict[str, Any]] = None) -> None # Extended channel token/allowlist mapping CHANNEL_ENV_MAP = { - "matrix": {"token": "MATRIX_ACCESS_TOKEN", "allowFrom": "MATRIX_ALLOWED_USERS", + "matrix": {"token": "[redacted:secret-assignment]", "tokenField": "[redacted:secret-assignment]", "allowFrom": "MATRIX_ALLOWED_USERS", "extras": {"homeserverUrl": "MATRIX_HOMESERVER_URL", "userId": "MATRIX_USER_ID"}}, "mattermost": {"token": "MATTERMOST_BOT_TOKEN", "allowFrom": "MATTERMOST_ALLOWED_USERS", "extras": {"url": "MATTERMOST_URL", "teamId": "MATTERMOST_TEAM_ID"}}, @@ -2160,19 +2213,21 @@ def migrate_deep_channels(self, config: Optional[Dict[str, Any]] = None) -> None if not ch_cfg: continue - # Extract tokens - if ch_mapping.get("token") and ch_cfg.get("botToken") and self.migrate_secrets: - self._set_env_var(ch_mapping["token"], ch_cfg["botToken"], - f"channels.{ch_name}.botToken") - if ch_mapping.get("allowFrom") and ch_cfg.get("allowFrom"): - allow_val = ch_cfg["allowFrom"] + # Extract tokens (check flat path, then accounts.default) + token_field = ch_mapping.get("tokenField", "botToken") + bot_token = [redacted:secret-assignment] token_field) + if ch_mapping.get("token") and bot_token and self.migrate_secrets: + self._set_env_var(ch_mapping["token"], str(bot_token), + f"channels.{ch_name}.{token_field}") + allow_val = self._get_channel_field(ch_cfg, "allowFrom") + if ch_mapping.get("allowFrom") and allow_val: if isinstance(allow_val, list): allow_val = ",".join(str(x) for x in allow_val) self._set_env_var(ch_mapping["allowFrom"], str(allow_val), f"channels.{ch_name}.allowFrom") # Extra fields for oc_key, env_key in (ch_mapping.get("extras") or {}).items(): - val = ch_cfg.get(oc_key) + val = self._get_channel_field(ch_cfg, oc_key) if val: if isinstance(val, list): val = ",".join(str(x) for x in val) @@ -2495,6 +2550,33 @@ def generate_migration_notes(self) -> None: elif has_cron_store_archive: notes.append("- Run `hermes cron` to recreate scheduled tasks (see archived cron-store)") + # Check if skills were imported + has_skills = any(i.kind == "skills" and i.status == "migrated" for i in self.items) + if has_skills: + notes.extend([ + "", + "## Imported Skills", + "", + "Imported skills require a new session to take effect. After migration,", + "restart your agent or start a new chat session, then run `/skills`", + "to verify they loaded correctly.", + "", + ]) + + # Check if WhatsApp was detected + has_whatsapp = any(i.kind == "whatsapp-settings" and i.status == "migrated" for i in self.items) + if has_whatsapp: + notes.extend([ + "", + "## WhatsApp Requires Re-Pairing", + "", + "WhatsApp uses QR-code pairing, not token-based auth. Your allowlist", + "was migrated, but you must re-pair the device by running:", + "", + " hermes whatsapp", + "", + ]) + notes.extend([ "- Run `hermes gateway install` if you need the gateway service", "- Review `~/.hermes/config.yaml` for any adjustments", -
tests/hermes_cli/test_claw.py@@ -289,12 +289,16 @@ def test_execute_with_confirmation(self, tmp_path, capsys): skill_conflict="skip", yes=False, ) + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + with ( patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), patch.object(claw_mod, "get_config_path", return_value=config_path), patch.object(claw_mod, "prompt_yes_no", return_value=True), patch.object(claw_mod, "_offer_source_archival"), + patch("sys.stdin", mock_stdin), ): claw_mod._cmd_migrate(args) @@ -377,6 +381,16 @@ def test_execute_cancelled_by_user(self, tmp_path, capsys): config_path = tmp_path / "config.yaml" config_path.write_text("") + # Preview must succeed before the confirmation prompt is shown + fake_mod = ModuleType("openclaw_to_hermes") + fake_mod.resolve_selected_options = MagicMock(return_value=set()) + fake_migrator = MagicMock() + fake_migrator.migrate.return_value = { + "summary": {"migrated": 1, "skipped": 0, "conflict": 0, "error": 0}, + "items": [{"kind": "soul", "status": "migrated", "source": "s", "destination": "d", "reason": ""}], + } + fake_mod.Migrator = MagicMock(return_value=fake_migrator) + args = Namespace( source=str(openclaw_dir), dry_run=False, preset="full", overwrite=False, @@ -384,9 +398,15 @@ def test_execute_cancelled_by_user(self, tmp_path, capsys): skill_conflict="skip", yes=False, ) + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + with ( patch.object(claw_mod, "_find_migration_script", return_value=tmp_path / "s.py"), + patch.object(claw_mod, "_load_migration_module", return_value=fake_mod), + patch.object(claw_mod, "get_config_path", return_value=config_path), patch.object(claw_mod, "prompt_yes_no", return_value=False), + patch("sys.stdin", mock_stdin), ): claw_mod._cmd_migrate(args) @@ -448,7 +468,7 @@ def test_handles_migration_error(self, tmp_path, capsys): claw_mod._cmd_migrate(args) captured = capsys.readouterr() - assert "Migration failed" in captured.out + assert "Could not load migration script" in captured.out def test_full_preset_enables_secrets(self, tmp_path, capsys): """The 'full' preset should set migrate_secrets=True automatically.""" @@ -511,7 +531,13 @@ def test_skips_when_user_declines(self, tmp_path, capsys): source = tmp_path / ".openclaw" source.mkdir() - with patch.object(claw_mod, "prompt_yes_no", return_value=False): + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + + with ( + patch.object(claw_mod, "prompt_yes_no", return_value=False), + patch("sys.stdin", mock_stdin), + ): claw_mod._offer_source_archival(source, auto_yes=False) captured = capsys.readouterr() @@ -597,10 +623,14 @@ def test_skips_when_user_declines(self, tmp_path, capsys): openclaw = tmp_path / ".openclaw" openclaw.mkdir() + mock_stdin = MagicMock() + mock_stdin.isatty.return_value = True + args = Namespace(source=None, dry_run=False, yes=False) with ( patch.object(claw_mod, "_find_openclaw_dirs", return_value=[openclaw]), patch.object(claw_mod, "prompt_yes_no", return_value=False), + patch("sys.stdin", mock_stdin), ): claw_mod._cmd_cleanup(args) -
website/docs/guides/migrate-from-openclaw.md@@ -11,30 +11,32 @@ description: "Complete guide to migrating your OpenClaw / Clawdbot setup to Herm ## Quick start ```bash -# Preview what would happen (no files changed) -hermes claw migrate --dry-run - -# Run the migration (secrets excluded by default) +# Preview then migrate (always shows a preview first, then asks to confirm) hermes claw migrate -# Full migration including API keys -hermes claw migrate --preset full +# Preview only, no changes +hermes claw migrate --dry-run + +# Full migration including API keys, skip confirmation +hermes claw migrate --preset full --yes ``` -The migration reads from `~/.openclaw/` by default. If you still have a legacy `~/.clawdbot/` or `~/.moldbot/` directory, it's detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`). +The migration always shows a full preview of what will be imported before making any changes. Review the list, then confirm to proceed. + +Reads from `~/.openclaw/` by default. Legacy `~/.clawdbot/` or `~/.moldbot/` directories are detected automatically. Same for legacy config filenames (`clawdbot.json`, `moldbot.json`). ## Options | Option | Description | |--------|-------------| -| `--dry-run` | Preview what would be migrated without writing anything. | +| `--dry-run` | Preview only — stop after showing what would be migrated. | | `--preset <name>` | `full` (default, includes secrets) or `user-data` (excludes API keys). | | `--overwrite` | Overwrite existing Hermes files on conflicts (default: skip). | | `--migrate-secrets` | Include API keys (on by default with `--preset full`). | | `--source <path>` | Custom OpenClaw directory. | | `--workspace-target <path>` | Where to place `AGENTS.md`. | | `--skill-conflict <mode>` | `skip` (default), `overwrite`, or `rename`. | -| `--yes` | Skip confirmation prompt. | +| `--yes` | Skip the confirmation prompt after preview. | ## What gets migrated @@ -48,7 +50,7 @@ The migration reads from `~/.openclaw/` by default. If you still have a legacy ` | User profile | `workspace/USER.md` | `~/.hermes/memories/USER.md` | Same entry-merge logic as memory. | | Daily memory files | `workspace/memory/*.md` | `~/.hermes/memories/MEMORY.md` | All daily files merged into main memory. | -All workspace files also check `workspace.default/` as a fallback path. +Workspace files are also checked at `workspace.default/` and `workspace-main/` as fallback paths (OpenClaw renamed `workspace/` to `workspace-main/` in recent versions, and uses `workspace-{agentId}` for multi-agent setups). ### Skills (4 sources) @@ -66,7 +68,7 @@ Skill conflicts are handled by `--skill-conflict`: `skip` leaves the existing He | What | OpenClaw config path | Hermes destination | Notes | |------|---------------------|-------------------|-------| | Default model | `agents.defaults.model` | `config.yaml` → `model` | Can be a string or `{primary, fallbacks}` object | -| Custom providers | `models.providers.*` | `config.yaml` → `custom_providers` | Maps `baseUrl`, `apiType` ("openai"→"chat_completions", "anthropic"→"anthropic_messages") | +| Custom providers | `models.providers.*` | `config.yaml` → `custom_providers` | Maps `baseUrl`, `apiType`/`api` — handles both short ("openai", "anthropic") and hyphenated ("openai-completions", "anthropic-messages", "google-generative-ai") values | | Provider API keys | `models.providers.*.apiKey` | `~/.hermes/.env` | Requires `--migrate-secrets`. See [API key resolution](#api-key-resolution) below. | ### Agent behavior @@ -75,7 +77,7 @@ Skill conflicts are handled by `--skill-conflict`: `skip` leaves the existing He |------|---------------------|-------------------|---------| | Max turns | `agents.defaults.timeoutSeconds` | `agent.max_turns` | `timeoutSeconds / 10`, capped at 200 | | Verbose mode | `agents.defaults.verboseDefault` | `agent.verbose` | "off" / "on" / "full" | -| Reasoning effort | `agents.defaults.thinkingDefault` | `agent.reasoning_effort` | "always"/"high" → "high", "auto"/"medium" → "medium", "off"/"low"/"none"/"minimal" → "low" | +| Reasoning effort | `agents.defaults.thinkingDefault` | `agent.reasoning_effort` | "always"/"high"/"xhigh" → "high", "auto"/"medium"/"adaptive" → "medium", "off"/"low"/"none"/"minimal" → "low" | | Compression | `agents.defaults.compaction.mode` | `compression.enabled` | "off" → false, anything else → true | | Compression model | `agents.defaults.compaction.model` | `compression.summary_model` | Direct string copy | | Human delay | `agents.defaults.humanDelay.mode` | `human_delay.mode` | "natural" / "custom" / "off" | @@ -122,26 +124,26 @@ TTS settings are read from **two** OpenClaw config locations with this priority: | ElevenLabs model ID | `config.yaml` → `tts.elevenlabs.model_id` | | OpenAI model | `config.yaml` → `tts.openai.model` | | OpenAI voice | `config.yaml` → `tts.openai.voice` | -| Edge TTS voice | `config.yaml` → `tts.edge.voice` | +| Edge TTS voice | `config.yaml` → `tts.edge.voice` (OpenClaw renamed "edge" to "microsoft" — both are recognized) | | TTS assets | `~/.hermes/tts/` (file copy) | ### Messaging platforms | Platform | OpenClaw config path | Hermes `.env` variable | Notes | |----------|---------------------|----------------------|-------| -| Telegram | `channels.telegram.botToken` | `TELEGRAM_BOT_TOKEN` | Token can be string or [SecretRef](#secretref-handling) | +| Telegram | `channels.telegram.botToken` or `.accounts.default.botToken` | `TELEGRAM_BOT_TOKEN` | Token can be string or [SecretRef](#secretref-handling). Both flat and accounts layout supported. | | Telegram | `credentials/telegram-default-allowFrom.json` | `TELEGRAM_ALLOWED_USERS` | Comma-joined from `allowFrom[]` array | -| Discord | `channels.discord.token` | `DISCORD_BOT_TOKEN` | | -| Discord | `channels.discord.allowFrom` | `DISCORD_ALLOWED_USERS` | | -| Slack | `channels.slack.botToken` | `SLACK_BOT_TOKEN` | | -| Slack | `channels.slack.appToken` | `SLACK_APP_TOKEN` | | -| Slack | `channels.slack.allowFrom` | `SLACK_ALLOWED_USERS` | | -| WhatsApp | `channels.whatsapp.allowFrom` | `WHATSAPP_ALLOWED_USERS` | Auth via Baileys QR pairing (not a token) | -| Signal | `channels.signal.account` | `SIGNAL_ACCOUNT` | | -| Signal | `channels.signal.httpUrl` | `SIGNAL_HTTP_URL` | | -| Signal | `channels.signal.allowFrom` | `SIGNAL_ALLOWED_USERS` | | -| Matrix | `channels.matrix.botToken` | `MATRIX_ACCESS_TOKEN` | Via deep-channels migration | -| Mattermost | `channels.mattermost.botToken` | `MATTERMOST_BOT_TOKEN` | Via deep-channels migration | +| Discord | `channels.discord.token` or `.accounts.default.token` | `DISCORD_BOT_TOKEN` | | +| Discord | `channels.discord.allowFrom` or `.accounts.default.allowFrom` | `DISCORD_ALLOWED_USERS` | | +| Slack | `channels.slack.botToken` or `.accounts.default.botToken` | `SLACK_BOT_TOKEN` | | +| Slack | `channels.slack.appToken` or `.accounts.default.appToken` | `SLACK_APP_TOKEN` | | +| Slack | `channels.slack.allowFrom` or `.accounts.default.allowFrom` | `SLACK_ALLOWED_USERS` | | +| WhatsApp | `channels.whatsapp.allowFrom` or `.accounts.default.allowFrom` | `WHATSAPP_ALLOWED_USERS` | Auth via Baileys QR pairing — requires re-pairing after migration | +| Signal | `channels.signal.account` or `.accounts.default.account` | `SIGNAL_ACCOUNT` | | +| Signal | `channels.signal.httpUrl` or `.accounts.default.httpUrl` | `SIGNAL_HTTP_URL` | | +| Signal | `channels.signal.allowFrom` or `.accounts.default.allowFrom` | `SIGNAL_ALLOWED_USERS` | | +| Matrix | `channels.matrix.accessToken` or `.accounts.default.accessToken` | `MATRIX_ACCESS_TOKEN` | Uses `accessToken` (not `botToken`) | +| Mattermost | `channels.mattermost.botToken` or `.accounts.default.botToken` | `MATTERMOST_BOT_TOKEN` | | ### Other config @@ -178,13 +180,14 @@ These are saved to `~/.hermes/migration/openclaw/<timestamp>/archive/` for manua ## API key resolution -When `--migrate-secrets` is enabled, API keys are collected from **three sources** in priority order: +When `--migrate-secrets` is enabled, API keys are collected from **four sources** in priority order: 1. **Config values** — `models.providers.*.apiKey` and TTS provider keys in `openclaw.json` 2. **Environment file** — `~/.openclaw/.env` (keys like `OPENROUTER_API_KEY`, `ANTHROPIC_API_KEY`, etc.) -3. **Auth profiles** — `~/.openclaw/agents/main/agent/auth-profiles.json` (per-agent credentials) +3. **Config env sub-object** — `openclaw.json` → `"env"` or `"env"."vars"` (some setups store keys here instead of a separate `.env` file) +4. **Auth profiles** — `~/.openclaw/agents/main/agent/auth-profiles.json` (per-agent credentials) -Config values take priority. The `.env` fills any gaps. Auth profiles fill whatever remains. +Config values take priority. Each subsequent source fills any remaining gaps. ### Supported key targets @@ -207,7 +210,7 @@ OpenClaw config values for tokens and API keys can be in three formats: "channels": { "telegram": { "botToken": { "source": "env", "id": "TELEGRAM_BOT_TOKEN" } } } ``` -The migration resolves all three formats. For env templates and SecretRef objects with `source: "env"`, it looks up the value in `~/.openclaw/.env`. SecretRef objects with `source: "file"` or `source: "exec"` can't be resolved automatically — those values must be added to Hermes manually after migration. +The migration resolves all three formats. For env templates and SecretRef objects with `source: "env"`, it looks up the value in `~/.openclaw/.env` and the `openclaw.json` env sub-object. SecretRef objects with `source: "file"` or `source: "exec"` can't be resolved automatically — the migration warns about these, and those values must be added to Hermes manually via `hermes config set`. ## After migration @@ -215,13 +218,17 @@ The migration resolves all three formats. For env templates and SecretRef object 2. **Review archived files** — anything in `~/.hermes/migration/openclaw/<timestamp>/archive/` needs manual attention. -3. **Verify API keys** — run `hermes status` to check provider authentication. +3. **Start a new session** — imported skills and memory entries take effect in new sessions, not the current one. + +4. **Verify API keys** — run `hermes status` to check provider authentication. + +5. **Test messaging** — if you migrated platform tokens, restart the gateway: `systemctl --user restart hermes-gateway` -4. **Test messaging** — if you migrated platform tokens, restart the gateway: `systemctl --user restart hermes-gateway` +6. **Check session policies** — verify `hermes config get session_reset` matches your expectations. -5. **Check session policies** — verify `hermes config get session_reset` matches your expectations. +7. **Re-pair WhatsApp** — WhatsApp uses QR code pairing (Baileys), not token migration. Run `hermes whatsapp` to pair. -6. **Re-pair WhatsApp** — WhatsApp uses QR code pairing (Baileys), not token migration. Run `hermes whatsapp` to pair. +8. **Archive cleanup** — after confirming everything works, run `hermes claw cleanup` to rename leftover OpenClaw directories to `.pre-migration/` (prevents state confusion). ## Troubleshooting @@ -231,7 +238,7 @@ The migration checks `~/.openclaw/`, then `~/.clawdbot/`, then `~/.moldbot/`. If ### "No provider API keys found" -Keys might be in your `.env` file instead of `openclaw.json`. The migration checks both — make sure `~/.openclaw/.env` exists and has the keys. If keys use `source: "file"` or `source: "exec"` SecretRefs, they can't be resolved automatically. +Keys might be stored in several places depending on your OpenClaw version: inline in `openclaw.json` under `models.providers.*.apiKey`, in `~/.openclaw/.env`, in the `openclaw.json` `"env"` sub-object, or in `agents/main/agent/auth-profiles.json`. The migration checks all four. If keys use `source: "file"` or `source: "exec"` SecretRefs, they can't be resolved automatically — add them via `hermes config set`. ### Skills not appearing after migration
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#7860 fix(claw): read API keys from openclaw.json "env" sub-object · merged via another change →
#7887,#7847
Closed issues · 1
- Closed#4652Bug: OpenClaw migration misses GEMINI_API_KEY; Feature: Context compressor needs input token cap and conditional summary model routing
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- hermes cli
- Merged PRs
- #7887
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 11 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.