Skip to content

openclaw.json config entry

源码版本v2026.6.11

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 $include directive 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

Data flow

At startup, createConfigIO first pins the path constants, then goes through loadConfigLocal (loadConfigLocal:L1654). Key steps of the read path:

typescript
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, materialize

Three 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:

typescript
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:

typescript
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 directly

Arrays 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, pinRuntimePaths must be called first (pinRuntimePaths:L236-L245); otherwise OPENCLAW_CONFIG_PATH set after module import reads the old value, causing a "read one path, write another" tear.
  • legacy fallback: resolveConfigPath scans clawdbot.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.
  • $include escape: by default, only references to files in the openclaw.json same 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, assertConfigWriteAllowedInCurrentMode rejects all writes; config is externally managed by Nix (resolveIsNixMode:L16-L18).
  • atomic write: replaceFileAtomic writes a temp file then renames; directory permissions 0o700, file permissions 0o600; Windows uses a copy fallback.
  • sudden size drop block: before writing, compares previousBytes vs nextBytes; if it drops below 50% without an allowConfigSizeDrop flag, the write is rejected — preventing a patch from wiping a whole provider block.
  • last-known-good: every successful read updates the lastKnownGood fingerprint (hash + bytes + mtime + dev/ino); if the next read sees a size crash or the gateway mode field wiped, it auto-restores from .bak and writes an audit log line config.observe event.
  • future version warning: when the file's meta.lastTouchedVersion is 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.