Openclaw · EN · Part 3

Ch 08 Configuration System And Schema Manageme

08-ch-08-Configuration-System-and-Schema-Manageme.md

第8章 Configuration System and Schema Management

Config Architecture Overview

OpenClaw's configuration system is built around a simple principle: the public contract is the single source of truth. When you change a config option, you update it once, and the change propagates everywhere it needs to go—types, schemas, docs, and validation. This isn't hand-coordinated; it's generated from a central Zod schema definition.

At the heart of this system is the OpenClawSchema (src/config/zod-schema.ts:1), a Zod object that defines every single configuration option across the entire application. From this one definition, OpenClaw derives:

// src/config/schema-base.ts
import { OpenClawSchema } from "./zod-schema.js";

const schema = OpenClawSchema.toJSONSchema({
  target: "draft-07",
  unrepresentable: "any",
});

The Zod-to-JSON-Schema transformation produces a 24,501-line generated schema file (src/config/schema.base.generated.ts). This isn't bloat—it's the complete, machine-readable contract for every config option, used by the Gateway, CLI, web UI, and plugin SDK.

🤿 深入底层: Why Zod? Zod provides runtime validation and TypeScript type inference from the same source. The z.string().min(1) definition becomes both a runtime validator and a TypeScript string type. OpenClaw chose Zod over alternatives like Joi or Yup because it supports discriminated unions, precise error messages, and seamless TypeScript integration—all critical for a config system that must be both developer-friendly and production-safe.

Public Contract Alignment

The config system maintains four aligned surfaces:

  1. TypeScript types (src/config/types.ts) — What developers import
  2. Zod schema (src/config/zod-schema.ts) — Runtime validation
  3. JSON Schema (schema.base.generated.ts) — Gateway and UI contracts
  4. Help text (src/config/schema.help.ts) — Documentation strings

When these surfaces drift apart, things break. A type might say string | null but the schema expects string | undefined. The help might document a timeout option that was renamed to timeoutMs. OpenClaw prevents this through drift detection.

# Detects when generated schema doesn't match current types
pnpm config:docs:check

# Regenerates all config artifacts when intentional change occurs
pnpm config:docs:gen

The drift detection uses SHA-256 hashes stored in docs/.generated/config-baseline.sha256. When you run the check command, it regenerates the schema, computes a new hash, and compares it against the committed hash. If they differ—and you didn't explicitly regenerate—you're prevented from landing the change.

// scripts/generate-config-doc-baseline.ts
const hash = createHash("sha256")
  .update(combinedBaseline)
  .digest("hex");

// This hash is committed to docs/.generated/config-baseline.sha256
// Drift occurs when regenerated hash != committed hash

Generated Baselines

OpenClaw doesn't just generate schema—it generates complete baseline JSON files that document every config option with its type, required status, enum values, defaults, and help text. These baselines power the web UI's config forms and the CLI's openclaw config --help output.

The baselines are split by domain:

  • config-baseline.core.json — Core OpenClaw config
  • config-baseline.channel.json — All channel plugin config
  • config-baseline.plugin.json — All non-channel plugin config

Each entry includes metadata like whether it's sensitive (should be redacted from logs), deprecated, or has special UI rendering requirements.

Schema Generation and Validation

The schema generation pipeline starts at OpenClawSchema and flows through multiple transformations:

Rendering diagram...

The schema generation (scripts/generate-base-config-schema.ts) takes the Zod definition and produces a TypeScript module exporting GENERATED_BASE_CONFIG_SCHEMA. This constant is consumed by the Gateway protocol, the web UI's config forms, and the CLI's validation logic.

Runtime Validation

When config is loaded, it passes through multiple validation layers:

// src/config/io.ts
export async function loadConfig(params?: { env?: NodeJS.ProcessEnv }) {
  // 1. Read and parse JSON5
  const parsed = await readConfigFileSnapshot(CONFIG_PATH);
  
  // 2. Apply legacy migrations
  const migrated = migrateLegacyConfig(parsed);
  
  // 3. Substitute environment variables
  const substituted = resolveConfigEnvVars(migrated);
  
  // 4. Validate against schema
  const validated = await validateConfigObjectWithPlugins(substituted);
  
  // 5. Materialize runtime config with defaults
  const materialized = materializeRuntimeConfig(validated, "load");
  
  return materialized;
}

Each layer has a specific responsibility. Legacy migrations handle old config key names. Env substitution resolves ${VAR} references. Schema validation catches type mismatches, unknown keys, and constraint violations. Materialization applies defaults and normalizes paths.

Plugin Config Contributions

Each plugin contributes its own config schema through the manifest system. A channel plugin like Telegram defines:

// extensions/telegram/openclaw.plugin.json
{
  "id": "telegram",
  "kind": "channel",
  "configSchema": {
    "type": "object",
    "properties": {
      "botToken": { "type": "string" },
      "streaming": { 
        "type": "string",
        "enum": ["off", "partial", "block"]
      }
    },
    "required": ["botToken"]
  }
}

The plugin loader merges these schemas into the main config schema at runtime, allowing the Gateway to validate plugin config without loading plugin code. This is why OpenClaw can show config errors for disabled plugins—their schemas are read from manifests, not from running code.

⚠️ 避坑指南: Plugin config schemas must be JSON Schema Draft 07, not Zod. The plugin SDK runs in a separate package boundary and cannot import zod. Use const for single-value enums, enum for multi-value options, and anyOf/oneOf for discriminated unions. Avoid additionalProperties: false unless you truly want to reject unknown keys—this breaks forward compatibility when new options are added.

Migration and Legacy Support

Config migrations in OpenClaw follow a strict philosophy: never silently break user config. When a config key is renamed or moved, a migration path must exist. The migration runs automatically when config is loaded, before validation.

The migration system lives in src/config/legacy.migrations.ts and is organized by domain:

// src/config/legacy.migrations.channels.ts
export const LEGACY_CONFIG_MIGRATIONS_CHANNELS = [
  {
    legacyRules: [{ path: "telegram.streamMode" }],
    migrate(config, changes) {
      const streamMode = config.telegram?.streamMode;
      if (streamMode !== undefined) {
        config.telegram ??= {};
        config.telegram.streaming ??= streamMode;
        delete config.telegram.streamMode;
        changes.push("telegram.streamMode → telegram.streaming");
      }
    },
  },
  // ... more migrations
];

Each migration spec receives the config object and a changes array for logging. The migration runs in-place, modifying the config directly. The changes array accumulates human-readable descriptions of what changed, which openclaw doctor --fix displays to the user.

Config Ingest Logic

The config ingest pipeline (src/config/io.ts) handles multiple edge cases:

// Environment variable substitution
const substituted = resolveConfigEnvVars(migrated);

// Include file resolution
const included = await resolveConfigIncludes(substituted);

// Secret reference resolution (OPENCLAW_SECRET_REF)
const resolvedSecrets = coerceSecretRef(included);

// Validation with plugins
const validated = await validateConfigObjectWithPlugins(resolvedSecrets);

Env substitution supports ${VAR} and ${VAR:-default} syntax. Include files allow splitting config across multiple files. Secret refs replace plaintext values with references to a secrets manager.

Doctor --fix Capabilities

The openclaw doctor --fix command (src/commands/doctor-config-flow.ts) is the primary interface for applying migrations and cleaning up config. It runs in a multi-stage flow:

  1. Preflight — Load config and run initial diagnostics
  2. Legacy migrations — Apply automatic key migrations
  3. Channel doctor — Remove stale channel plugin references
  4. Auto-enable — Enable plugins based on detected config
  5. Repair sequence — Apply specific fixes for known issues
  6. Finalize — Prompt user and write config if changes detected
// src/commands/doctor-config-flow.ts
const legacyStep = applyLegacyCompatibilityStep({
  snapshot,
  state: { cfg, candidate, pendingChanges, fixHints },
  shouldRepair,
  doctorFixCommand,
});

if (legacyStep.issueLines.length > 0) {
  note(legacyStep.issueLines.join("\n"), "Legacy config keys detected");
}

The candidate pattern is key: doctor creates a copy of the config, applies changes to the copy, and only writes back if the user confirms. This allows preview mode (openclaw doctor without --fix) to show what would change without touching the file.

☕ 茶水间时间: The hooks.internal.handlers legacy migration is intentionally manual. This config array held inline hook module paths, which OpenClaw replaced with the managed hook directory system. An automatic migration would require creating hook files and rewriting imports—a operation too risky to automate. Users must manually migrate each handler to a hooks/internal/<hookKey>/ directory with HOOK.md and handler.js. This is an example of OpenClaw choosing safe manual migration over risky automation.

Config at Scale

Production deployments of OpenClaw face unique config challenges: multiple environments (dev/staging/prod), team collaboration on config changes, and runtime config overrides without restarts. OpenClaw addresses these through config snapshotting, validation pipelines, and Gateway state synchronization.

Config Snapshotting

The Gateway maintains a runtime snapshot of the current config, which nodes can query without re-reading from disk:

// src/config/runtime-snapshot.ts
let runtimeConfigSnapshot: RuntimeConfig | null = null;
let runtimeConfigSourceSnapshot: ResolvedSourceConfig | null = null;

export function setRuntimeConfigSnapshot(
  config: RuntimeConfig,
  source: ResolvedSourceConfig,
) {
  runtimeConfigSnapshot = config;
  runtimeConfigSourceSnapshot = source;
}

// Gateway RPC method: config.getRuntimeConfigSnapshot
export function handleGetRuntimeConfigSnapshot() {
  return {
    config: runtimeConfigSnapshot,
    source: runtimeConfigSourceSnapshot,
    schema: GENERATED_BASE_CONFIG_SCHEMA,
  };
}

When config is reloaded (via SIGHUP or a write listener), the snapshot is updated and all connected Gateway clients receive a config.changed event. This allows mobile apps and web UIs to stay in sync without polling.

Validation Pipelines

Production deployments should run config validation before applying changes:

# CI pipeline: check config schema drift
pnpm config:docs:check

# Pre-deploy: validate config against all enabled plugins
pnpm test -- src/config/config.schema-regressions.test.ts

# Runtime: openclaw doctor in pre-commit hook
prek install  # Installs the hook

The schema regression tests ensure that changes to Zod schemas don't silently invalidate existing valid config. They do this by maintaining a fixture of valid configs and asserting they still pass validation after schema changes.

Gateway State Relationship

The Gateway's runtime state is derived from config but not identical to it. Some config options are materialized differently at runtime:

// src/config/materialize.ts
export type ConfigMaterializationMode = "load" | "missing" | "snapshot";

const MATERIALIZATION_PROFILES: Record<ConfigMaterializationMode, MaterializationProfile> = {
  load: {
    includeTalkApiKey: false,
    includeCompactionDefaults: true,
    includeContextPruningDefaults: true,
    includeLoggingDefaults: true,
    normalizePaths: true,
  },
  // ... other profiles
};

Different materialization modes apply different defaults. The "load" mode (used when reading config from disk) applies full defaults and normalizes paths. The "snapshot" mode (used for Gateway responses) includes additional fields like talkApiKey that are derived at runtime but not persisted to disk.

🤿 深入底层: Why multiple materialization modes? Because config flows through different contexts with different needs. When loading from disk, you want minimal defaults applied—only what's required for the config to be valid. When responding to a Gateway query, you want the full runtime state including derived values. When generating a config template for a new user, you want extensive defaults to minimize required edits. The materialization profile system ensures each context gets the right level of default injection.

Multi-environment Best Practices

For deployments across multiple environments:

  1. Use profile directories — OpenClaw supports --profile <name> to load config from .openclaw/config.<profile>.json instead of the default
  2. Environment-specific includes — Use include: "./config.production.json" to share common config
  3. Secret management — Store sensitive values in environment variables, reference them with ${VAR:-default} syntax
  4. Validation gates — Run openclaw doctor in CI to catch config errors before deploy

The config system provides the foundation for OpenClaw's reliability. A well-typed, validated config prevents runtime errors, enables safe migrations, and supports the 95+ plugin ecosystem without chaos.


Chapter Exercises

  1. Add a new config option with full schema support
    Add a debug.showInternalTraces boolean option to the config schema. Update the Zod definition, regenerate the schema, and verify the drift check passes.

  2. Write a migration for a legacy key
    The agents.maxConcurrent key is being renamed to agents.concurrency.max. Write a migration in legacy.migrations.runtime.ts that moves existing values to the new location.

  3. Run config drift detection
    Make a trivial change to src/config/zod-schema.ts (add a comment). Run pnpm config:docs:check to see drift detection fail, then run pnpm config:docs:gen to regenerate the baseline.

With config mastered, let's explore how OpenClaw manages sessions and state across conversations.