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 TypeScriptstringtype. 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:
- TypeScript types (
src/config/types.ts) — What developers import - Zod schema (
src/config/zod-schema.ts) — Runtime validation - JSON Schema (
schema.base.generated.ts) — Gateway and UI contracts - 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 configconfig-baseline.channel.json— All channel plugin configconfig-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. Useconstfor single-value enums,enumfor multi-value options, andanyOf/oneOffor discriminated unions. AvoidadditionalProperties: falseunless 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:
- Preflight — Load config and run initial diagnostics
- Legacy migrations — Apply automatic key migrations
- Channel doctor — Remove stale channel plugin references
- Auto-enable — Enable plugins based on detected config
- Repair sequence — Apply specific fixes for known issues
- 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.handlerslegacy 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 ahooks/internal/<hookKey>/directory withHOOK.mdandhandler.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:
- Use profile directories — OpenClaw supports
--profile <name>to load config from.openclaw/config.<profile>.jsoninstead of the default - Environment-specific includes — Use
include: "./config.production.json"to share common config - Secret management — Store sensitive values in environment variables, reference them with
${VAR:-default}syntax - Validation gates — Run
openclaw doctorin 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
-
Add a new config option with full schema support
Add adebug.showInternalTracesboolean option to the config schema. Update the Zod definition, regenerate the schema, and verify the drift check passes. -
Write a migration for a legacy key
Theagents.maxConcurrentkey is being renamed toagents.concurrency.max. Write a migration inlegacy.migrations.runtime.tsthat moves existing values to the new location. -
Run config drift detection
Make a trivial change tosrc/config/zod-schema.ts(add a comment). Runpnpm config:docs:checkto see drift detection fail, then runpnpm config:docs:gento regenerate the baseline.
With config mastered, let's explore how OpenClaw manages sessions and state across conversations.