Zod Schema runtime validation
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:
- TypeScript-native: the schema definition directly derives the
OpenClawConfigtype; changing the schema equals changing the type, so inconsistencies surface at compile time. - 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 viavalidateConfigObjectWithPlugins— something JSON Schema can't do dynamically. - Readable errors: issues returned by
safeParsecarry paths;mapZodIssueToConfigIssuetranslates Zod's internal issues into a flat list ofpath + message, so the CLI can point precisely to deep paths likemodels.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
OpenClawSchema top level:L495-L560— root schema, aggregating all sub-schemas: models / channels / agents / approvals / session / cron / skills / memory / mcp / hooks / gateway / proxy.ModelsConfigSchema:L551-L558— themodelsblock, withmode: merge|replaceandproviders.ModelProvidersSchema:L518-L540— provider registry;superRefineforces custom providers to havebaseUrlandmodels[]; bundled overlays are exempt.zod-schema.providers-core.ts— main provider fields (baseUrl / api / auth / apiKey / headers / models[]), also reused by channels.ChannelsSchema:L52— channel config, dynamic record; each channel id corresponds to a channel schema provided by the channel plugin.InternalHooksSchema:L97-L113— internal hooks config, trigger to action mapping.zod-schema.session.ts— session policies, message retention, send policies.zod-schema.approvals.ts— tool call approval policies (native exec / file write etc.).zod-schema.agents.ts— multi-agent config (agent list / audio / bindings / broadcast).cron schema:L849-L860—enabled/store/maxConcurrentRuns/retry.skills schema:L1260-L1310—allowBundled/load/install/limitsfour sub-fields.validateConfigObjectRaw:L1041-L1105— Zod validation entry; ifsafeParsefails, maps to issues.validateConfigObject:L1107-L1124— on top of raw, stacksmaterializeRuntimeConfigto produce a ready-to-use runtime config.validateConfigObjectWithPlugins:L1149-L1183— stacks plugin schema validation; plugins inject their own field constraints viavalidateConfigObjectWithPluginsBase.io.invalid-config.ts—throwInvalidConfig, formats issues into an error message and tagsINVALID_CONFIGcode.defaults.ts:L109-L529— "soft defaults" outside the schema:applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaultsetc., called at thematerializeRuntimeConfigstage, not written back to disk.
Data flow
The validation entry is validateConfigObjectRaw (validateConfigObjectRaw:L1041). The core is a single safeParse:
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:
stripDeprecatedValidationKeyswipes deprecated fields before validation, so the schema can use.strict()to reject unknown fields without breaking old configs;policyIssuesis the policy check outside the schema (like whether a secret ref is mutable), merged with Zod issues in the return;materializeBundledModelProviderOverlaysmaterializes built-in provider overlays after validation passes, so user config that just writes theopenaiid inherits the bundled default model list.
ModelsConfigSchema itself (ModelsConfigSchema:L551) is small:
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):
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
validateConfigObjectRawdoesn't pass,loadConfigLocaldirectly goes throughthrowInvalidConfig(src/config/io.ts:L1752-L1757); the error carries anINVALID_CONFIGcode, andloadConfigLocal'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 andModelsConfigSchemause.strict(), directly rejecting misspelled fields likeprovidrs, 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: trueis 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:VisibleRepliesSchemaaccepts"automatic" | "message_tool"orboolean; the latter is converted to the enum via.overwrite()(VisibleRepliesSchema:L563-L573). This normalization lets old configs that writetrueavoid migration..transform()type coercion:DiscordIdSchemaacceptsstring | number, but Discord snowflakes lose precision aboveNumber.MAX_SAFE_INTEGER, so the transform stage coerces to string and validates non-negative (DiscordIdSchema).- future version guard:
meta.lastTouchedVersionis checked byshouldWarnOnTouchedVersion; a newer config read by an older version only warns, doesn't reject. But the schema's ownlastTouchedAtacceptsstring | number; numbers get transformed to ISO strings, avoiding parse failures after an agent script writesDate.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
validateConfigObjectRawonly validates channel-common fields; channel-internal field validation is filled in bycollectRawBundledChannelConfigIssuesafter plugin load. - preserved legacy root keys: when doctor fixes legacy keys,
preservedLegacyRootKeyslets 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.