openclaw.json config entry
Responsibilities
openclaw.json is OpenClaw's single author-side config file: gateway port, model providers, channel credentials, skill load paths, cron, memory policies, approvals, hooks — all written in this one JSON5 file. All runtime config is read from here, then run through include expansion, env variable substitution, Zod schema validation, and default value injection to become the in-memory OpenClawConfig object.
It isn't just a "read once and done" file: CLI's config set/patch/unset writes back atomically, the runtime snapshot is compared against on-disk content to prevent races, and on trouble it can roll back from a last-known-good backup. So this entry handles five things: read, write, validate, backup, migrate.
Design motivation
Why a single file instead of a scattered directory? Early OpenClaw stuffed provider, channel, memory each into their own JSON, with the result that editing one provider required touching three files, and after a channel plugin upgrade the migration logic had to be scattered everywhere. After consolidating into a single openclaw.json, version stamps, backups, and audit logs all hang on the same file, and openclaw doctor can run a health check by scanning one path.
But a single file also bloats, so two escape hatches are supported:
- The
$includedirective splits large provider/channel config blocks into sub-files, with the root file keeping only references; ${VAR}environment variable substitution keeps secrets out of the file and in a vault.
These two features let openclaw.json go into a git repo (once redacted) and run on Docker/Tailscale-style env-only deployments.
Key files
paths.ts CONFIG_FILENAME:L25-L26—openclaw.jsonfilename constant, with legacyclawdbot.jsonfallback alongside.resolveStateDir:L58-L87— resolves the state dir;$OPENCLAW_STATE_DIRoverrides, otherwise~/.openclaw, otherwise legacy.clawdbot.resolveCanonicalConfigPath:L152-L161— canonical path$OPENCLAW_CONFIG_PATHor$stateDir/openclaw.json.resolveConfigPath:L191-L226— on actual read, prefers existing candidate files (including legacy); if none found, falls back to canonical.normalizeStateDirEnv:L89-L95— at startup expands~in$OPENCLAW_STATE_DIRand writes it back to env; first step of the gateway startup sequence.resolveIncludeRoots:L117-L143—$OPENCLAW_INCLUDE_ROOTSwhitelist, allows$includeto cross out of the config directory.createConfigIO:L1378-L1434— IO facade factory; encapsulates read, write, snapshot, backup, validate all in one flow.loadConfigLocal:L1654-L1828— sync read-path core: dotenv → read file → JSON5 parse → include expansion → env substitution → validate → materialize runtime config.writeConfigFileLocal:L2269-L2366— write-path core: compare snapshot, generate merge-patch, validate, atomic write to disk.writeConfigFile export:L2826-L2860— public write entry; projects runtime snapshot back to source form before writing.applyMergePatch:L83— JSON merge patch implementation, basis of patch mode.applyConfigOverrides:L93— runtime override injection (tests, CLI temporary args).env-substitution.ts—${VAR}resolution; missing variable only warns, doesn't crash.includes.ts—$includedirective resolution, with path-escape protection.defaults.ts apply*:L109-L529—applyMessageDefaults/applyAgentDefaults/applyCronDefaults/applyCompactionDefaultsetc. default value injection.runConfigPatch:L2279-L2309—openclaw config patchCLI entry.mergeConfigValue:L771-L790— recursive merge during patch; providermodels[]arrays take a special dedup path.
Data flow
At startup, createConfigIO first pins the path constants, then goes through loadConfigLocal (loadConfigLocal:L1654). Key steps of the read path:
function loadConfigLocal(options: { skipSuspiciousRecovery?: boolean } = {}): OpenClawConfig {
try {
maybeLoadDotEnvForConfig(deps.env);
const envBeforeRead = snapshotEnv(deps.env);
if (!deps.fs.existsSync(configPath)) {
// ... load shell env fallback then return empty config
return {};
}
const raw = deps.fs.readFileSync(configPath, "utf-8");
const parsed = deps.json5.parse(raw);
const readResolution = resolveConfigForRead(
resolveConfigIncludesForRead(parsed, configPath, deps),
deps.env,
deps.lowerPrecedenceEnv,
);
// ... shipped plugin install records migration, validate, materializeThree things to note: maybeLoadDotEnvForConfig only actually loads dotenv when env equals process.env (test-injected env isn't touched); envBeforeRead is used at write-back to detect "env changed during read"; resolveConfigIncludesForRead reads in $include target files, then hands off to resolveConfigForRead for ${VAR} substitution. Order is critical: include before env substitution, so that fields brought in by include can also use ${VAR}.
The write path goes through writeConfigFile (writeConfigFile:L2826). When a runtime snapshot exists, it first diffs the caller's runtime-form config into a patch, then projects back to source form:
export async function writeConfigFile(
cfg: OpenClawConfig,
options: ConfigWriteOptions = {},
): Promise<ConfigWriteResult> {
options.assertConfigPathForWrite?.();
const io = createConfigIO({ /* ... */ });
assertConfigWriteAllowedInCurrentMode({ configPath: io.configPath });
let nextCfg = cfg;
const runtimeConfigSnapshot = getRuntimeConfigSnapshotState();
const runtimeConfigSourceSnapshot = getRuntimeConfigSourceSnapshotState();
const hadBothSnapshots = Boolean(runtimeConfigSnapshot && runtimeConfigSourceSnapshot);
if (hadBothSnapshots) {
const runtimePatch = createMergePatch(runtimeConfigSnapshot!, cfg);
nextCfg = coerceConfig(applyMergePatch(runtimeConfigSourceSnapshot!, runtimePatch));
}This step ensures the caller writes back the "user author form", not the runtime-default-polluted form — otherwise schema defaults would be pinned into the file and unrecoverable on version change.
CLI patch mode (runConfigPatch:L2279) takes a JSON5 patch object and recursively merges it into the existing config:
function mergeConfigValue(existing: unknown, patch: unknown, path: PathSegment[]): unknown {
if (isProviderModelListPath(path) && Array.isArray(existing) && Array.isArray(patch)) {
return mergeModelArrays(existing, patch);
}
if (isPlainRecord(existing) && isPlainRecord(patch)) {
// deep merge: recurse into objects, non-objects overwrite directlyArrays like models[] go through mergeModelArrays, which dedups by id field and merges elements rather than simply replacing — appending a model to the provider list doesn't wipe the existing models.
The relationship between the read, write, and patch paths:
Boundaries and failures
- path drift: at gateway startup,
pinRuntimePathsmust be called first (pinRuntimePaths:L236-L245); otherwiseOPENCLAW_CONFIG_PATHset after module import reads the old value, causing a "read one path, write another" tear. - legacy fallback:
resolveConfigPathscansclawdbot.json,.clawdbot/first, then the new path. When upgrading an old deployment, if both directories exist, the new path wins; but if the user mistakenly leaves old files in~/.openclaw/, the new process will also read them — the doctor command specifically scans for this split. $includeescape: by default, only references to files in theopenclaw.jsonsame directory and subdirectories are allowed. Cross-directory references require explicitly setting$OPENCLAW_INCLUDE_ROOTS, and each root is tilde-expanded + deduped + rejects non-absolute paths (include roots filter:L128-L141).${VAR}missing: doesn't throw, only warns "feature using this value will be unavailable" (src/config/io.ts:L1950-L1956); gateway enters degraded mode rather than refusing to start. But a missing provider apiKey will error when actually sending a request.- Nix mode read-only: when
OPENCLAW_NIX_MODE=1,assertConfigWriteAllowedInCurrentModerejects all writes; config is externally managed by Nix (resolveIsNixMode:L16-L18). - atomic write:
replaceFileAtomicwrites a temp file then renames; directory permissions0o700, file permissions0o600; Windows uses a copy fallback. - sudden size drop block: before writing, compares
previousBytesvsnextBytes; if it drops below 50% without anallowConfigSizeDropflag, the write is rejected — preventing a patch from wiping a whole provider block. - last-known-good: every successful read updates the
lastKnownGoodfingerprint (hash + bytes + mtime + dev/ino); if the next read sees a size crash or the gateway mode field wiped, it auto-restores from.bakand writes an audit log lineconfig.observeevent. - future version warning: when the file's
meta.lastTouchedVersionis newer than the current binary, warn that the user's PATH may be pointing at an older openclaw — preventing "new-version-written config being corrupted by an older version read".
Summary
openclaw.json is a single entry, but behind it is a whole read-validate-materialize-write-audit lifecycle. The read path expands include and env layer by layer; the write path projects runtime form back to source form; patch mode provides structured incremental edits for the CLI. How schema defines the legal shape of each field is in Zod Schema runtime validation; provider-specific fields (baseUrl/api/auth/apiKey/models[]) are detailed in LLM Providers; channel-side config is in Channel config; how the daemon process uses the read config is in Daemon.
Official references: Configuration docs · README.