Skip to content

Zod Schema runtime validation

源码版本v2026.6.11

Responsibilities

Zod schema is OpenClaw config's runtime contract: as soon as openclaw.json is read in, it's passed through OpenClawSchema.safeParse. All field shapes, enum values, provider required fields, and cross-field constraints stand guard here. If the schema doesn't pass, throwInvalidConfig throws INVALID_CONFIG directly and the gateway refuses to start — no silent fallback to lax defaults.

The schema module also handles some default value injection and shape normalization: .default(), .transform(), .overwrite(), .superRefine() — four kinds of hooks run in one safeParse call, producing a ready-to-use OpenClawConfig object.

Design motivation

Why Zod instead of JSON Schema or hand-written if-else? Three reasons:

  1. TypeScript-native: the schema definition directly derives the OpenClawConfig type; changing the schema equals changing the type, so inconsistencies surface at compile time.
  2. Composable: provider, channel, approvals, cron, skills each have their own schema, assembled at the top level of OpenClawSchema. Plugins can also inject their own schema fragments via validateConfigObjectWithPlugins — something JSON Schema can't do dynamically.
  3. Readable errors: issues returned by safeParse carry paths; mapZodIssueToConfigIssue translates Zod's internal issues into a flat list of path + message, so the CLI can point precisely to deep paths like models.providers.openai.models[0].id.

The cost is that the schema file itself is huge (zod-schema.core.ts is over a thousand lines), and Zod's runtime overhead on large configs is non-negligible — but compared to the cost of "config errors causing runtime crashes", it's worth it.

Key files

Data flow

The validation entry is validateConfigObjectRaw (validateConfigObjectRaw:L1041). The core is a single safeParse:

typescript
export function validateConfigObjectRaw(
  raw: unknown,
  opts?: {
    sourceRaw?: unknown;
    touchedPaths?: ReadonlyArray<ReadonlyArray<string>>;
    validateBundledChannels?: boolean;
    preservedLegacyRootKeys?: readonly string[];
  },
): { ok: true; config: OpenClawConfig } | { ok: false; issues: ConfigValidationIssue[] } {
  const normalizedRaw = stripPreservedLegacyRootKeysForValidation(
    stripDeprecatedValidationKeys(raw),
    opts?.preservedLegacyRootKeys,
  );
  const policyIssues = collectUnsupportedSecretRefPolicyIssues(normalizedRaw);
  const validated = OpenClawSchema.safeParse(normalizedRaw);
  if (!validated.success) {
    const schemaIssues = validated.error.issues.map((issue) => mapZodIssueToConfigIssue(issue));
    return {
      ok: false,
      issues: mergeUnsupportedMutableSecretRefIssues(policyIssues, schemaIssues),
    };
  }
  const validatedConfig = materializeBundledModelProviderOverlays(validated.data as OpenClawConfig);

Three details worth noting:

  • stripDeprecatedValidationKeys wipes deprecated fields before validation, so the schema can use .strict() to reject unknown fields without breaking old configs;
  • policyIssues is the policy check outside the schema (like whether a secret ref is mutable), merged with Zod issues in the return;
  • materializeBundledModelProviderOverlays materializes built-in provider overlays after validation passes, so user config that just writes the openai id inherits the bundled default model list.

ModelsConfigSchema itself (ModelsConfigSchema:L551) is small:

typescript
export const ModelsConfigSchema = z
  .object({
    mode: z.union([z.literal("merge"), z.literal("replace")]).optional(),
    providers: ModelProvidersSchema.optional(),
    pricing: ModelPricingConfigSchema,
  })
  .strict()
  .optional();

.strict() means any undefined field in the models block (like mistakenly writing apiKey under models instead of providers.openai) is rejected. mode: "merge" is the default — fields of the bundled provider and the user-declared same-id provider are merged; mode: "replace" fully replaces the bundled definition, for custom OpenAI-compatible endpoints.

ModelProvidersSchema uses .superRefine() for cross-field validation (ModelProvidersSchema:L518-L540):

typescript
const ModelProvidersSchema = z
  .record(z.string(), ModelProviderSchema)
  .superRefine((providers, ctx) => {
    for (const [providerId, provider] of Object.entries(providers)) {
      if (isBuiltInModelProviderOverlayId(providerId)) {
        continue;
      }
      if (!provider.baseUrl) {
        ctx.addIssue({
          code: "custom",
          path: [providerId, "baseUrl"],
          message:
            "custom model providers must declare baseUrl; provider overlays without baseUrl are only supported for bundled providers",
        });
      }

isBuiltInModelProviderOverlayId checks whether the id is a bundled one like openai / anthropic / google; if so, only partial field overrides are allowed. Custom ids (like my-local-llm) must explicitly provide baseUrl and models[], otherwise rejected. This avoids the "configured a provider but has no baseUrl, only discovered at runtime" awkwardness.

The whole validation chain:

Boundaries and failures

  • fail closed: when validateConfigObjectRaw doesn't pass, loadConfigLocal directly goes through throwInvalidConfig (src/config/io.ts:L1752-L1757); the error carries an INVALID_CONFIG code, and loadConfigLocal's catch explicitly checks this code and doesn't fall back — ensuring a broken config doesn't let the gateway come up.
  • .strict() spell-check: both the top-level schema and ModelsConfigSchema use .strict(), directly rejecting misspelled fields like providrs, avoiding "wrote the wrong key and silently ignored".
  • plugin schema injection: third-party plugins inject their own schema fragments via validateConfigObjectWithPlugins; on failure, the issue path carries the plugin prefix. skipPluginValidation: true is an emergency fix path for known plugin schema bugs.
  • default vs optional: many fields use the .default("allowlist").optional() pattern — .optional() lets the author omit; .default() ensures runtime always gets a value. This distinction makes "user didn't write" and "user wrote null" semantically different; doctor can use it to decide whether backfill is needed.
  • .overwrite() normalization: VisibleRepliesSchema accepts "automatic" | "message_tool" or boolean; the latter is converted to the enum via .overwrite() (VisibleRepliesSchema:L563-L573). This normalization lets old configs that write true avoid migration.
  • .transform() type coercion: DiscordIdSchema accepts string | number, but Discord snowflakes lose precision above Number.MAX_SAFE_INTEGER, so the transform stage coerces to string and validates non-negative (DiscordIdSchema).
  • future version guard: meta.lastTouchedVersion is checked by shouldWarnOnTouchedVersion; a newer config read by an older version only warns, doesn't reject. But the schema's own lastTouchedAt accepts string | number; numbers get transformed to ISO strings, avoiding parse failures after an agent script writes Date.now() into the file.
  • channel schema lazy binding: channel is a dynamic record; the schema for each channel id is registered by the channel plugin at load, so validateConfigObjectRaw only validates channel-common fields; channel-internal field validation is filled in by collectRawBundledChannelConfigIssues after plugin load.
  • preserved legacy root keys: when doctor fixes legacy keys, preservedLegacyRootKeys lets these keys skip validation but remain in the file, avoiding a one-shot failure of all old configs after a schema tightening.

Summary

Zod schema is OpenClaw config's gatekeeper, defining both static shape and cross-field policy and normalization. safeParse runs all checks in one call; failures fail closed. provider / channel / agent each have their own schema files, assembled at the top of OpenClawSchema; plugins can inject dynamically. Combined with the read path of openclaw.json config entry, the whole read-validate-materialize chain ensures broken configs never reach runtime; channel-side specific fields are in Channel config.

Official references: Configuration Schema · README.