Skip to content

Config Source Code Analysis

Module Overview

The Config module manages all configuration for OpenCode — from model selection to permission rules, from MCP servers to keybindings. It employs a Zod Schema-driven type system combined with priority-based merging across six configuration layers, allowing project-level shared configuration and user personal preferences to coexist in an orderly fashion.

Core design choices:

  • Full Zod Schema coverage: All type definitions use Zod, providing runtime validation and compile-time type safety
  • Six-layer configuration priority: Built-in defaults → Global config → Environment variable file → Project config → .opencode/ → Environment variable content
  • JSONC + variable substitution: JSON format with comment support, plus env:VAR / file:path placeholder syntax for runtime substitution
  • mergeDeep + array concatenation: Deep merging based on remeda, with plugins / instructions and other array fields concatenated rather than overwritten
  • Full hot-reload: On configuration changes, Instance.dispose()Instance.state() performs a destroy-rebuild cycle to ensure consistency

Config is also built on the Effect.ts architecture, exposing a service interface through Config.Service and managing each project’s configuration lifecycle via InstanceState.

Key Files

File PathLinesResponsibility
src/config/config.ts~2600Module main file: all Zod Schema definitions, six-layer loading logic, mergeDeep merging, variable substitution, Service definition
src/config/paths.ts~30ConfigPaths helper: path computation for global/project-level config files
src/config/markdown.ts~20ConfigMarkdown type: Markdown rendering related configuration
src/config/tui-schema.ts~50TUI config Schema: Zod definitions for keybindings, themes, layout and other UI configuration
src/config/tui.ts~100TUI config logic: loading and processing of TUI-related configuration
src/config/tui-migrate.ts~60TUI config migration: automatic migration from old config format to new format
src/config/console-state.ts~20ConsoleState type: console output state management
.opencode/config.jsonProject-level configuration file (can be committed to Git)
~/.config/opencode/config.jsonUser global configuration

Type System

Permission Schema — 14+ Operation Types

The permission operation type is one of the most core Schemas in Config, defining all categories of operations that an Agent can perform:

// Permission action tri-state enum
const PermissionAction = z.enum(["ask", "allow", "deny"]);

// Permission rules: independent control over 14+ operations
const Permission = z.object({
  read: PermissionAction.default("allow"),               // Read files
  edit: PermissionAction.default("ask"),                 // Edit files
  bash: PermissionAction.default("ask"),                 // Execute Shell commands
  glob: PermissionAction.default("allow"),               // File pattern search
  grep: PermissionAction.default("allow"),               // File content search
  list: PermissionAction.default("allow"),               // Directory listing
  task: PermissionAction.default("allow"),               // Subtask execution
  external_directory: PermissionAction.default("ask"),   // Access directories outside project
  todowrite: PermissionAction.default("ask"),            // Write TODO
  todoread: PermissionAction.default("allow"),           // Read TODO
  question: PermissionAction.default("ask"),             // Ask user questions
  webfetch: PermissionAction.default("ask"),             // Network requests
  websearch: PermissionAction.default("ask"),            // Web search
  codesearch: PermissionAction.default("ask"),           // Code search
  lsp: PermissionAction.default("ask"),                  // LSP operations
  doom_loop: PermissionAction.default("ask"),            // Doom Loop confirmation
}).catchall(PermissionAction.default("ask"));

Design intent: The default policy is “read operations allow, write operations ask”, balancing efficiency and safety. The catchall ensures that newly added tools are not accidentally permitted — any unlisted operation type defaults to the ask flow.

Provider Schema — Model Provider Configuration

const Provider = z.object({
  apiKey: z.string().optional(),          // API key (supports {env:} substitution)
  baseURL: z.string().optional(),         // Custom API endpoint
  disabled: z.boolean().optional(),       // Whether to disable this Provider
  models: z.record(z.string(), z.object({
    // Model-level override configuration
    disabled: z.boolean().optional(),
    // ...
  })).optional(),
});

Mcp Schema — MCP Server Configuration

const Mcp = z.discriminatedUnion("type", [
  z.object({
    type: z.literal("local"),             // Local MCP (stdio mode)
    command: z.string(),                  // Launch command, e.g. "npx"
    args: z.array(z.string()).optional(), // Command arguments
    env: z.record(z.string()).optional(), // Environment variables
    disabled: z.boolean().optional(),
  }),
  z.object({
    type: z.literal("remote"),            // Remote MCP (SSE mode)
    url: z.string(),                      // SSE endpoint URL
    headers: z.record(z.string()).optional(),
    disabled: z.boolean().optional(),
  }),
]);

discriminatedUnion allows both MCP modes to coexist with type safety — TypeScript can automatically narrow the remaining fields based on the type field.

Agent Schema — Agent Custom Configuration

const Agent = z.object({
  model: z.string().optional(),           // Bound model
  prompt: z.string().optional(),          // Custom system prompt
  permission: Permission.optional(),      // Override permission rules
  temperature: z.number().optional(),     // Temperature parameter
  topP: z.number().optional(),            // top_p parameter
  disabled: z.boolean().optional(),       // Whether disabled
  steps: z.number().int().positive().optional(), // Maximum steps
});

Command Schema — Custom Commands

const Command = z.object({
  description: z.string().optional(),     // Command description
  agent: z.string().optional(),           // Specify executing Agent
  template: z.string(),                   // Command template text
  subtask: z.boolean().optional(),        // Whether subtask mode
});

Info — Top-Level Aggregation Schema

All sub-configurations converge into the top-level Info Schema:

const Info = z.object({
  model: ModelSchema.optional(),          // Default model selection
  provider: z.record(Provider).optional(), // Provider configuration dictionary
  agent: z.record(Agent).optional(),      // Custom Agent configuration
  mcp: z.record(Mcp).optional(),          // MCP server configuration
  permission: Permission.optional(),      // Global permission rules
  command: z.array(Command).optional(),   // Custom command list
  keybinds: Keybinds.optional(),          // Keybindings
  tui: TUI.optional(),                    // TUI configuration
  server: Server.optional(),              // Server configuration
  // ... other fields
});

Each sub-Schema uses .default() to provide fallback values, ensuring the system can still function normally when any configuration item is missing. Info.parse() is the final gate in the configuration loading chain — any configuration that does not conform to the Schema is intercepted here with a clear error message.

Core Flow

Six-Layer Configuration Loading Priority

Config loading is not a simple “file overwrites file”, but a six-layer discovery-merge process (from lowest to highest priority):

Layer 1: Built-in Defaults
  │  Values defined by .default() in Schemas

Layer 2: Global Configuration Files
  │  ~/.config/opencode/config.json
  │  ~/.config/opencode/opencode.json
  │  ~/.config/opencode/opencode.jsonc

Layer 3: OPENCODE_CONFIG Environment Variable
  │  Config file path specified by environment variable
  │  Suitable for CI/CD scenarios or temporary overrides

Layer 4: Project Configuration (findUp)
  │  Searches upward from current directory for opencode.jsonc / opencode.json
  │  Can be committed to Git, shared by team members

Layer 5: .opencode/ Directory
  │  .opencode/config.json within the project
  │  Not committed to Git (should be in .gitignore), personal preferences

Layer 6: OPENCODE_CONFIG_CONTENT Environment Variable
  │  JSON content passed directly via environment variable (highest priority)
  │  Suitable for containerized deployments, avoiding config file mounting

Note: CLI flags are not part of the merge chain. Command-line arguments override directly at the call site rather than participating in mergeDeep. For example, --model anthropic/claude-sonnet-4 would directly override Config’s model selection at the SessionPrompt layer.

Loading and Parsing: JSONC + Variable Substitution

The load() function handles the complete loading process for a single configuration file:

load(filePath)
  ├─ Read file content (Bun.file().text())
  ├─ JSONC parsing
  │   └─ jsonc-parser's parseJsonC()
  │      Supports single-line comments //, multi-line comments /* */, trailing commas
  ├─ Variable substitution (recursively traverse all string values)
  │   ├─ {env:VAR} → process.env.VAR
  │   │   Example: {env:OPENAI_API_KEY} → sk-xxxxx
  │   ├─ {file:path} → Read external file content
  │   │   Example: {file:./prompts/system.txt} → file content string
  │   └─ Unmatched environment variables → Keep original string and output warning
  └─ Return parsed raw object (not yet Zod-validated)

{env:VAR} substitution allows sensitive information like API keys to remain in environment variables rather than being written in plaintext to configuration files. {file:path} supports importing external files (such as long prompt templates), keeping configuration files concise. Variable substitution is executed before Zod validation, so Schemas can perform type checking on the actual substituted values.

Merge Strategy: mergeDeep + Array Concatenation

Multi-layer configuration merging uses remeda’s mergeDeep, enhanced with array concatenation for specific fields:

function mergeConfigConcatArrays(base: Info, ...overrides: Info[]): Info {
  // Deep merge based on remeda.mergeDeep
  // Special behavior:
  //   - plugins, instructions, command and other array fields → concatenated, not overwritten
  //   - provider, mcp, agent and other dictionary fields → deep merged
  //   - model, temperature and other simple values → latter overrides former
  return overrides.reduce((acc, override) => {
    return customMerge(acc, override)
  }, base)
}

Merge behavior summary:

Field TypeMerge StrategyExample
Simple values (model, temperature)Latter overrides formerProject model overrides global model
Dictionaries (provider, mcp, agent)Deep merge, merged by keyGlobal provider.openai + project provider.openai → merged
Arrays (plugins, instructions, command)Concatenated, not overwrittenGlobal plugins + project plugins = combined list

This ensures that project-level plugins do not overwrite user-level plugins, but rather merge — both configurations can complement each other.

Plugin Deduplication: deduplicatePlugins

After merging, duplicate plugins may exist (both global and project configurations declaring the same plugin). deduplicatePlugins() deduplicates by canonical name:

function deduplicatePlugins(plugins: Plugin[]): Plugin[] {
  // Group by canonical name
  // Keep the highest-priority one in each group (i.e., the plugin from the later-loaded config source)
  // Canonical name: package name with version and scope removed
  // Example: @scope/my-plugin@1.0.0 and @scope/my-plugin@2.0.0 are treated as the same
  const seen = new Map<string, Plugin>()
  for (const plugin of plugins) {
    const canonical = toCanonical(plugin.name)
    seen.set(canonical, plugin)  // Later writes override earlier ones
  }
  return [...seen.values()]
}

Deduplication rule: Plugins from higher-priority config sources win. Because the plugins array is concatenated from lowest to highest priority, a same-named plugin appearing later naturally overrides the one appearing earlier.

Config.Service — Effect Service Architecture

The Config module exposes its interface through an Effect Service:

export class Service extends Effect.Service<Service>("Config.Service")(
  undefined, // Service context definition
  () =>
    Effect.gen(function* () {
      // Initialize configuration state
      const cfg = yield* loadConfig()
      return {
        get: () => cfg,
        update: (newConfig) => updateConfig(newConfig),
        // ...
      }
    }),
) {}

Upper-level modules inject the configuration service via Effect.provide(Service.Default) and access the current configuration via Config.get(). Effect’s dependency injection allows configuration to be easily replaced in tests.

InstanceState Caching

Config loading results are cached via Instance.state(), bound to the project instance’s lifecycle:

const state = Instance.state(async () => {
  // Six-layer config loading + merging + validation
  return await loadAllLayers()
})

export async function get() {
  return state()  // Executes the factory function on first call, returns cached result thereafter
}

Instance.state() returns an async function that executes the factory function on the first call and caches the result. The cache is automatically cleared when Instance.dispose() is called — meaning the “destroy-rebuild” on configuration updates triggers a full reload.

Configuration Update and Full Hot-Reload

The update() function implements hot configuration updates using a “destroy-rebuild” strategy:

update(newConfig)
  ├─ JSON.stringify(newConfig, null, 2)
  ├─ fs.writeFile(projectConfigPath, content)
  │  └─ Write to project-level .opencode/config.json
  ├─ Instance.dispose(currentState)
  │  └─ Destroy all caches for the current instance
  │     ├─ Config state cache cleanup
  │     ├─ Agent state cache cleanup
  │     ├─ Provider state cache cleanup
  │     └─ MCP connection closure
  └─ Instance.state()
     └─ Reload, triggering the full initialization chain:
        ├─ Load all configuration layers (six layers)
        ├─ mergeDeep merging
        ├─ deduplicatePlugins deduplication
        ├─ Zod Schema validation
        ├─ Install dependencies (plugins)
        ├─ Load commands/agents/modes/plugins (via glob pattern scanning)
        └─ Build new InstanceState

Design tradeoff: Full reload was chosen over incremental updates. This sacrifices reload performance but avoids complex state synchronization issues. Incremental updates would require tracking “which modules depend on which configuration items”, which is extremely expensive to maintain in a loosely-coupled architecture. In practice, the reload window is very short (typically under 100ms), since configuration loading itself is just file reads and Schema validation.

TUI Configuration and Migration

TUI (Terminal UI) configuration has its own Schema and migration logic, supporting automatic upgrades from older versions:

// tui-migrate.ts — Automatic migration from old config format
function migrate(oldConfig: unknown): TUIConfig {
  // Detect old format and convert to new format
  // Example: old keybind field names mapped to new keybinds
  // Migration is auto-saved, transparent to the user
}

TUI configuration includes keybindings, theme colors, panel layout, and other UI details. It is separated out because TUI configuration changes frequently and has a different lifecycle from core business configuration (models, permissions).

Call Chain Examples

Chain 1: Loading Configuration at Project Startup

User runs opencode in a project directory


Instance.state()  // First call, triggers configuration loading

  ├─ loadBuiltinDefaults()
  │   └─ Returns built-in defaults defined by .default() in Schemas

  ├─ loadGlobalConfig()
  │   └─ ConfigPaths.global() → ~/.config/opencode/
  │   └─ Attempt to read config.json / opencode.json / opencode.jsonc (by priority)
  │   └─ load(globalConfigPath) → JSONC parsing + variable substitution

  ├─ loadEnvConfig()
  │   └─ process.env.OPENCODE_CONFIG → config file at specified path
  │   └─ If exists → load(envConfigPath)

  ├─ loadProjectConfig()
  │   └─ findUp("opencode.jsonc", "opencode.json")
  │   └─ Search upward from cwd, stop at first match
  │   └─ load(projectConfigPath)

  ├─ loadOpencodeDir()
  │   └─ .opencode/config.json
  │   └─ load(opencodeDirPath)

  ├─ loadEnvContent()
  │   └─ process.env.OPENCODE_CONFIG_CONTENT → Parse JSON directly

  ├─ mergeConfigConcatArrays(defaults, global, env, project, opencodeDir, envContent)
  │   └─ Merge six layers from lowest to highest priority
  │   └─ Array fields concatenated, dictionary fields deep merged, simple values overwritten

  ├─ deduplicatePlugins(merged.plugins)
  │   └─ Deduplicate by canonical name

  ├─ Info.parse(result)
  │   └─ Zod Schema validation, type-safe configuration object

  └─ Store in InstanceState cache

Chain 2: Environment Variable Substitution for API Keys

Configuration file opencode.jsonc:
{
  "provider": {
    "openai": {
      "apiKey": "{env:OPENAI_API_KEY}",  // Placeholder
      "baseURL": "{env:CUSTOM_OPENAI_URL}"
    }
  }
}


load(filePath)
  ├─ Read file content
  ├─ JSONC.parse(content)
  │   └─ Supports // and /* */ comments
  │   └─ Result: { provider: { openai: { apiKey: "{env:OPENAI_API_KEY}", ... } } }
  ├─ Variable substitution (recursively traverse all string values)
  │   ├─ "{env:OPENAI_API_KEY}" → process.env.OPENAI_API_KEY → "sk-xxxxx"
  │   ├─ "{env:CUSTOM_OPENAI_URL}" → process.env.CUSTOM_OPENAI_URL → "https://..."
  │   └─ Result: { provider: { openai: { apiKey: "sk-xxxxx", baseURL: "https://..." } } }
  ├─ Provider Schema validation
  └─ Runtime apiKey is the actual value; no plaintext keys in config file

Chain 3: Configuration Change Propagation (Full Reload)

User changes model selection in TUI (from claude-sonnet-4 → gpt-5)


Config.update({ model: "openai/gpt-5" })

  ├─ Read current project configuration
  ├─ Merge new value into configuration object
  ├─ fs.writeFile(projectConfigPath, JSON.stringify(newConfig, null, 2))

  ├─ Instance.dispose(currentState)
  │   ├─ Clear Config state cache
  │   ├─ Clear Provider state cache (close old SDK instances)
  │   ├─ Clear Agent state cache
  │   ├─ Close MCP server connections
  │   └─ Broadcast Instance disposed event

  └─ Instance.state() → Triggers full re-initialization
     ├─ Reload six-layer configuration
     ├─ Provider initialization: new model gpt-5 registered in Provider.Info
     ├─ Agent initialization: reads updated configuration
     ├─ MCP reconnection: starts MCP servers per new configuration
     └─ All modules depending on Config receive the new configuration

Chain 4: Global + Project Configuration Merging

Global config ~/.config/opencode/config.json:
{
  "provider": {
    "anthropic": { "apiKey": "{env:ANTHROPIC_API_KEY}" },
    "openai": { "apiKey": "{env:OPENAI_API_KEY}" }
  },
  "plugins": ["plugin-a", "plugin-b"],
  "permission": {
    "bash": "ask"    // Global default: bash requires ask
  }
}

Project config ./opencode.jsonc:
{
  "provider": {
    "anthropic": {
      "disabled": true   // Project disables Anthropic
    }
  },
  "plugins": ["plugin-c"],
  "permission": {
    "bash": "allow",     // Project override: bash auto-allowed
    "edit": "allow"      // Project override: edit auto-allowed
  }
}


Merged result:
{
  provider: {
    anthropic: { apiKey: "sk-xxx", disabled: true },  // Deep merge
    openai: { apiKey: "sk-yyy" }
  },
  plugins: ["plugin-a", "plugin-b", "plugin-c"],      // Array concatenation
  permission: {
    bash: "allow",     // Project overrides global
    edit: "allow"      // Project addition
  }
}

Chain 5: External File Import (file:path placeholder substitution)

Configuration file opencode.jsonc:
{
  "agent": {
    "custom-reviewer": {
      "prompt": "{file:./prompts/review-prompt.md}",  // Import external file
    }
  }
}

File ./prompts/review-prompt.md content:
You are a professional code reviewer. Please check the following aspects:
1. Security vulnerabilities
2. Performance issues
3. Code style consistency
...


After variable substitution:
{
  agent: {
    "custom-reviewer": {
      prompt: "You are a professional code reviewer. Please check the following aspects:\n1. Security vulnerabilities\n2. Performance issues\n3. Code style consistency\n..."
    }
  }
}

Design Tradeoffs

DecisionRationale
JSONC instead of YAMLJSONC supports comments while maintaining JSON compatibility, with a better tooling ecosystem (IDE syntax highlighting, mature JSONC parsers). YAML’s indentation sensitivity and implicit type conversions easily introduce bugs
Full reload vs. incremental updateChose full reload, sacrificing reload performance but avoiding complex state synchronization issues. Incremental updates would require tracking “which modules depend on which configuration items”, which is prohibitively expensive to maintain in a loosely-coupled architecture
Zod Schema-drivenCompared to manual type definitions, Zod provides both runtime validation and compile-time type inference — one Schema, dual guarantees. z.infer<typeof Schema> automatically derives TypeScript types from the Schema
Array concatenation instead of overwriteConcatenation behavior for plugins/instructions/command fields lets global and project configurations complement each other rather than being mutually exclusive. With overwrite, users would need to redeclare global content in project configuration
Six layers rather than unlimited inheritanceSix priority layers cover common use cases (defaults, global, CI, team sharing, personal preferences, containers). More layers would increase cognitive overhead and debugging difficulty
findUp upward searchSearching upward from cwd for configuration files supports running opencode in subdirectories of a monorepo while automatically finding the root configuration
catchall defaults to askNew tool types won’t be accidentally permitted due to missing explicit configuration; safety first
InstanceState cachingConfiguration loading involves multi-layer file reads and Schema validation; caching avoids repeated overhead. Cache lifecycle is bound to the project instance and automatically invalidated when switching projects

Relationships with Other Modules

  • Provider: During initialization, Provider reads model configuration and API keys from Config (actual values after {env:VAR} variable substitution). Config.get().provider provides Provider registration and authentication information
  • Permission: Permission rules are defined in Config via the Permission Schema; Permission.fromConfig() parses the configuration into runtime Rulesets. Config’s catchall default ensures safety for new tool types
  • Agent: Agent behavior parameters (max loop count, default model, temperature, permission overrides) are read from Config’s agent field. The Agent list is cached via Instance.state()
  • MCP: The MCP server list (mcp field) and its configuration come from Config, supporting both local (stdio) and remote (SSE) modes. MCP configuration changes trigger reconnections
  • Command: Custom commands are defined via Config’s command array, merged with built-in commands and registered in the Command module
  • CLI / TUI: TUI keybindings (keybinds), themes (tui), and other UI configuration come from Config. TUI configuration has its own migration logic
  • Session: InstanceState shares its lifecycle with Session; configuration changes trigger Session-level reloads. Session reads Config during initialization to determine model and Agent configuration
  • Plugin: The Plugin list is loaded from Config; deduplicatePlugins() ensures each plugin is loaded only once. Plugin enable/disable is controlled through configuration
  • Instance: Config caching is managed via Instance.state() and automatically cleaned up on Instance.dispose(). Configuration updates trigger a full instance rebuild