# Openclaw Migration

## Decision

Gini ships an in-process migrator that translates a user's openclaw state into the gini instance under `~/.gini/instances/<instance>/`. The surface is `gini import plan openclaw [path]` (read-only, redacted summary) and `gini import apply openclaw [path] [--force]` (mutates). Both run in-process — no gateway needs to be up, no bearer token to wire, no openclaw daemon to keep alive. The migrator covers seven subsystems gini implements natively: a verbatim zip archive of the openclaw state root, agents, provider API keys, messaging bridges (Telegram + Discord), skills + workspace bootstrap markdown, chat session transcripts, and Hindsight memory units. Everything else (file-chunk RAG memory, tasks/cron, plugins, paired devices, non-Telegram-non-Discord channels) is surfaced in an `unsupported` list so the operator sees the gap explicitly instead of inheriting silent state loss.

A round-trip "export everything, transform, replay" pipeline was rejected. So was a wire-protocol bridge against openclaw's HTTP gateway. So was running openclaw and gini side-by-side as peers indefinitely. Each shape buys flexibility we don't need and ships complexity (a JSON5 schema mirror, a gateway dependency, dual-write reconciliation) that would outlive the migration window.

Migration NEVER deletes openclaw data — we only read from the source. As an additional safety net, the very first step of `apply` zips the entire openclaw state root into `<instance>/imports/openclaw-<timestamp>.zip`. If the archive fails to write, the migration aborts before any state mutation lands. The safety net is non-optional and reflects the operator's expectation that running migration cannot put their originals at risk.

## Context

Openclaw and gini have overlapping conceptual surfaces — agents, channels, skills, sessions, provider auth — but the storage shapes don't agree. Openclaw spreads state across `~/.openclaw/openclaw.json` (strict JSON with optional JSONC-style line/block comments and trailing commas — full JSON5 features like unquoted keys, single-quoted strings, or hex literals are NOT supported by the migrator's parser), per-agent dirs (`agents/<id>/agent/{auth-profiles,auth-state,models}.json`), per-agent JSONL session transcripts (`agents/<id>/sessions/`), credentials at `credentials/<channel>-{pairing,allowFrom}.json`, a SQLite memory store at `memory/<id>.sqlite`, an extensions plugin tree, and several other subsystem-specific files. Gini concentrates state in a single `state.json` per instance plus the encrypted `secrets/` directory, the `memory.db` SQLite store, JSONL trace files, and a flat `skills/` directory. Path resolution honors `OPENCLAW_STATE_DIR` (openclaw side, mirrored by the migrator) and `GINI_STATE_ROOT` / `GINI_INSTANCE` (gini side, picked up by the harness).

The operational question driving the migration shape: a user running openclaw today should be able to land on gini without re-pairing every Telegram chat or re-typing every API key. The answer is a one-shot translator that reads openclaw's well-known paths, builds a plan, prints a redacted summary, and — on `apply` — emits the same `mutateState` + `writeSecret` + `writeKeyToSecretsEnv` calls the live HTTP endpoints already use. Identity follows the same audit and trust contracts the rest of the runtime trusts.

Why not the openclaw HTTP gateway? The gateway exposes a bearer-auth surface, but openclaw's installation might not be running when the user wants to migrate. Requiring the operator to start openclaw, mint a token, paste it into the gini CLI, then mutate gini through a remote read pipeline solves a problem the migrator doesn't have: the source state already lives on the same machine as the destination. Direct disk reads are simpler, do not need authentication, and tolerate openclaw being uninstalled before the migration runs.

Why not a generalized "import any AI agent" framework? Gini already carries `ImportSource = "hermes" | "openclaw"` and a generic `inspectImportSource()` that walks a path for file counts. The existing surface is inspection-only and intentionally generic so it can grow per-source detail without re-typing the type. The new openclaw-specific code lives in a dedicated module (`src/integrations/openclaw-migrate.ts`) so each source can carry the schema knowledge it needs without forcing the others into a common abstraction. A hermes-specific apply path can follow the same pattern when one is needed.

JSONC tolerance is a real concern. Openclaw documents the config as "JSON or JSON5", but in the wild every config we have seen uses only the JSONC subset (`// comments`, `/* block comments */`, and trailing commas). The migrator ships a small commentary-and-trailing-comma stripper that retries strict `JSON.parse` on a cleaned-up string; full JSON5 features (unquoted keys, single-quoted strings, hex / Infinity / NaN literals, multi-line strings) are deliberately NOT supported because we have never observed them in the wild and bringing in a full JSON5 dependency for that long tail is more risk than payoff. Anything that still fails after the cleanup is either real syntax or a JSON5-only construct the user needs to rewrite as strict JSON before re-migrating.

## Required Now

- `gini import plan openclaw [path]` reads the openclaw state root (honoring an explicit path argument, then `OPENCLAW_STATE_DIR`, then `~/.openclaw`, then the legacy `~/.clawdbot`) and prints a redacted JSON summary covering: agents to be created, provider env vars to be written (no plaintext), messaging bridges to be configured (no plaintext tokens; allow-list counts AND the explicit chat ids — the IDs are intentionally surfaced so a tampered backup cannot smuggle a foreign chat past the operator's plan diff), skills to be copied, workspace files to be copied, chat sessions + message counts, Hindsight memory units, and an `unsupported` array enumerating every subsystem the migrator does not translate (file-chunk RAG memory, tasks/cron, plugins, paired devices, non-Telegram-non-Discord channels).
- `gini import apply openclaw [path] [--force]` runs the same plan and then mutates gini state in-process. Provider keys land in `~/.gini/secrets.env` via the shared `writeKeyToSecretsEnv` writer at mode 0600. Connector secrets (the Telegram and Discord bot tokens) go through `writeSecret(instance, "messaging.<bridgeId>", "bot-token", value)` — the same per-instance AES-256-GCM box the live `POST /api/messaging` endpoint uses (see [Connector Secret Storage](connector-secret-storage.md)). Agent records, messaging-bridge records, and the final `ImportReport` row land through `mutateState(instance, …)` so the per-instance lock, audit chain, and `normalizeState` invariants run unchanged.
- The migrator pre-generates a bridge id with `id("bridge")` before calling `writeSecret`, so the secret file path (`messaging.<bridgeId>.bot-token.json`) and the bridge record agree before insertion. Only one bridge per kind exists per instance — re-applying with `--force` rotates the encrypted token file and merges new allow-list state into the existing bridge instead of forking it.
- Skill frontmatter conversion handles three input shapes openclaw produces and translates them so gini's skill loader (per [Skills As Packages, Connectors As Credentials](skills-and-connectors.md)) can read the `metadata.gini.*` extension fields. The loader is a hand-rolled YAML-ish parser, not a full YAML implementation, so the rewriter has to honor its quirks:
  1. **Flow-style under metadata** (the dominant bundled shape from openclaw's bundled skills): `metadata:\n  { "openclaw": { … } }`. The migrator parses the brace block with the same tolerant JSON reader used for `openclaw.json` itself, then re-emits the namespace contents as block-style YAML under `metadata:\n  gini:`. The scan is anchored to the line immediately after `metadata:` so a sibling field whose value happens to contain braces is not mistaken for the metadata value.
  2. **Block-style nested under metadata**: `metadata:\n  openclaw:\n    …`. Renamed to `metadata:\n  gini:\n    …` with indentation preserved.
  3. **Legacy top-level**: `openclaw:\n  …`. Promoted to `metadata:\n  gini:\n    …` with child indentation bumped by two spaces. The promotion is skipped when a top-level `metadata:` block already exists — emitting two sibling `metadata:` keys would be invalid YAML and the loader silently drops one half.
- Scalar emission inside the block-YAML output picks a quote style per value that the loader's `parseScalar` (which strips outer quotes without unescaping anything) can round-trip: single quotes when the value has no apostrophe, double quotes when it has no double quote, bare for plain identifiers, and a deliberate throw when both quote styles would appear inside the literal (no representable form). Strings that would otherwise re-parse as a non-string scalar (`"false"`, `"42"`, `"~"`) get forced into quotes; inline arrays fall back to block style when any element contains a comma since the loader's array parser splits on `,` without quote awareness. A throw inside emission catches at the `convertFlowStyleMetadata` boundary and leaves the original openclaw frontmatter intact rather than emitting a half-converted block.
- Sibling files in the skill directory (`scripts/`, `references/`, etc.) are copied with `cpSync(... { recursive: true })`. Same-named existing skills are skipped unless `--force` is passed; `--force` propagates into the sibling copy so both `SKILL.md` and `scripts/*` refresh together.
- Workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, `BOOTSTRAP.md`, `MEMORY.md` — the full set mirrors openclaw's own `BOOTSTRAP_FILENAMES` constant) are copied from `~/.openclaw/workspace/` (honoring `OPENCLAW_WORKSPACE_DIR` and `OPENCLAW_PROFILE`) into the gini instance workspace. Same-named files are skipped unless `--force` — operators often hand-edit these and a silent overwrite would be a surprise.
- The Telegram allow-list under `<state>/credentials/telegram-allowFrom.json` (shape `{ version: 1, allowFrom: string[] }`) is read and the string ids are coerced to numbers for `bridge.metadata.allowedChatIds`. Non-numeric entries are dropped — Telegram chat ids are integers in the Bot API; a string id is an openclaw artifact that wouldn't match against an inbound `update.chat.id` anyway.
- Provider mapping handles the subset gini supports natively: `openai`, `codex`, `openrouter`, and the `local` family (`local`, `lmstudio`, `ollama`, `vllm`). Other providers (Anthropic, Google, etc.) are recorded in the `unsupported` list with a `provider:<name>` kind so the operator can wire them by hand.
- The plan summary type is structurally distinct from the internal plan type. `MigrationPlan` carries plaintext token values needed by `applyMigration`; `summarizePlan(plan)` returns a `MigrationPlanSummary` that omits every plaintext field and exposes env-var names, source paths, allow-list counts, AND the actual allow-list chat ids. The allow-list ids are intentionally surfaced (not hidden behind a scalar count) so a tampered backup cannot smuggle a foreign Telegram chat past the operator's plan diff — chat ids are integers and small in number, and showing them is the only cheap mitigation against silent permission widening. The CLI ALWAYS prints the summary; the full plan is only consumed in-process by `applyMigration`. A test asserts the summary never contains the source tokens (`expect(serialized).not.toContain("sk-test-from-openclaw")`).
- A single `ImportReport` row records each apply, with `mode: "applied"` (the new enum variant — `mode` was previously pinned to `"inspect"`) and a `counts` map that mirrors what was written. The migrator extends the type by widening `ImportReport.mode` to `"inspect" | "applied"`; the existing inspection surface continues to write `"inspect"` unchanged.
- Step 0 of `apply` shells out to the PATH-resolved `zip` binary (via `node:child_process spawnSync` with `-rqy`) to write `<instance>/imports/openclaw-<timestamp>.zip` containing the entire openclaw state root. PATH resolution lets the operator's package manager pick (`/usr/bin/zip` on stock macOS/Linux, `/opt/homebrew/bin/zip` on Apple Silicon Homebrew, `/usr/local/bin/zip` on Intel Homebrew or some Linux distros) — pinning an absolute path would break portability. The migration aborts BEFORE any other step if the archive fails. Failure modes the migrator surfaces explicitly: `zip` binary missing on the operator's PATH, exit code != 0 with stderr captured, parent imports directory blocked by a file. The archive is intentionally kept on disk indefinitely; the operator decides when to delete it.
- Sessions and memory units are visible in the plan summary as their own step kinds (`session`, `memoryUnit`) with print-safe shapes (`messageCount`, `sourceBank`, `network`). The summary counts surface `sessions`, `sessionMessages`, and `memoryUnits` so the operator sees the volume up front without the migrator having to re-read every JSONL or SQLite row.

## Security Boundary

- Plaintext secret values never appear in CLI output. `summarizePlan` strips them by construction; the apply path consumes them in-process and routes them straight into the encrypted on-disk stores. Audit rows record source and counts, never the secret material itself.
- The migrator reads openclaw state but never writes back. Removing `~/.openclaw/` after the migration finishes is safe and is recommended for users who want a clean cut-over.
- Connector tokens land in the same per-instance encrypted store the live HTTP create endpoint uses. The per-instance key is generated on first `writeSecret` call (see `src/state/secrets.ts:42-51`); a stolen state.json without the matching `.key` file decrypts to nothing.
- Idempotency reduces blast radius. The default behavior is "create what is missing, leave what exists alone"; `--force` is a deliberate operator gesture for rotation. The migrator does not delete openclaw state, does not stop the openclaw daemon, and does not touch state that already exists on the gini side unless `--force` is set.
- Provider keys are written to `~/.gini/secrets.env` rather than the per-instance encrypted store because the installed `gini` wrapper sources that file with `set -a; . ~/.gini/secrets.env; set +a` so the env var is visible to provider HTTP calls. The encrypted store would force a runtime decrypt+export hook on every provider invocation, which the local-first shape does not need.

## Sessions and Memory

- **Chat session transcripts.** Each `<state>/agents/<id>/sessions/<sessionId>.jsonl` becomes one `ChatSessionRecord` plus one `ChatMessageRecord` per `type: "message"` line, under the gini agent whose name matches the openclaw agent id. Tool_use and tool_result blocks are dropped from migrated message text because `ChatMessageRecord.content` is a flat string; the verbatim transcript stays in the archive zip for anyone who needs the original tool-call detail. Session `createdAt` is rebased to the JSONL header's timestamp and `updatedAt` to the last message's timestamp so recent-chats sort reflects the original openclaw transcript date, not migration day. Sessions that filter down to zero text-bearing messages (all-tool transcripts) are skipped with a warning instead of producing an empty `ChatSessionRecord`.
- **Hindsight memory translation.** Openclaw memory stores ship in at least two schemas. When `<state>/memory/*.sqlite` carries the Hindsight shape (`memory_banks` + `memory_units`), each unit row is inserted into the gini instance's `memory.db` with `embedding: null`. The operator runs `gini embedding reembed --all-banks` after migration to populate vectors using the configured embedding provider across every per-agent bank — re-embedding text avoids the cross-provider vector pollution that a copy-the-embedding strategy would introduce. Status and network values are coerced to safe gini defaults (`active` / `experience`) when openclaw uses a value gini doesn't recognize, so a schema drift can't poison recall. Bank binding is load-bearing for recall (see `agent-memory-isolation.md`): every recall channel filters on `bank_id = bankIdForAgent(agentId) AND agent_id = ?`, so a unit inserted under `DEFAULT_BANK_ID` with `agentId: null` would be invisible to the live recall surface. The migrator therefore resolves each unit's openclaw SQLite filename (the `sourceBank` label) to the gini agent of the same name and inserts into that agent's bank via `ensureAgentBank` + `bankIdForAgent`. Units whose `sourceBank` does NOT match a migrated agent fall back to `DEFAULT_BANK_ID` with `agentId: null` and a warning telling the operator they must reassign `agent_id` manually before recall will surface them. Every migrated unit is also tagged with `metadata.openclawBank` + `metadata.openclawUnitId` so the operator can still trace lineage and so re-applying the migration is idempotent. The alternative file-chunk RAG schema (`chunks` + `files` + `embedding_cache`) has no direct gini target and lands on `unsupported` with a `Re-index via /api/memory/retain` hint.
- **Embedding provider dependency.** The migrator does NOT call the embedding provider during apply. Migrated memory units have `embedding: null`, `embeddingModel: null`. Semantic recall won't return them until the operator runs `gini embedding reembed --all-banks`. `--all-banks` enumerates every bank in the instance (including each `bank_<agentId>` the migrator created); plain `gini embedding reembed` only walks the default bank and would leave per-agent units unembedded. The migration finishes successfully on a system with no embedding provider configured; the reembed step is the right place to require one because it's where the provider call actually happens.

## Open Questions

- **Per-agent memory and toolset filters.** Openclaw's `AgentConfig` carries rich `sandbox`, `workspace`, and `cliBackends` fields that have no clean gini counterpart today. The migrator translates the bare identity (id, name, model, providerName) and stamps every imported agent with gini's default toolset set; per-agent filters need a richer ADR before they can ride on the migrator.
- **Non-Telegram, non-Discord channels.** Openclaw's bundled channel set (Slack, WhatsApp, Signal, iMessage, IRC, Matrix, Feishu, etc.) is broader than gini's. Each new gini bridge ADR (per the [Telegram](telegram-bridge.md) / [Discord](discord-bridge.md) precedent) will add a row to the migrator's channel table; until then, those channels are surfaced as `channel:<name>` entries in the `unsupported` list so the operator knows the gap.
- **Reverse migration.** A gini → openclaw direction is not implemented. The asymmetry is intentional — gini is the destination — but if a user wants to roll back, the openclaw state was never deleted, so they can stop gini and re-start openclaw. A reverse migrator can be added later if there's demand.
