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:pathplaceholder syntax for runtime substitution - mergeDeep + array concatenation: Deep merging based on remeda, with
plugins/instructionsand 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 Path | Lines | Responsibility |
|---|---|---|
src/config/config.ts | ~2600 | Module main file: all Zod Schema definitions, six-layer loading logic, mergeDeep merging, variable substitution, Service definition |
src/config/paths.ts | ~30 | ConfigPaths helper: path computation for global/project-level config files |
src/config/markdown.ts | ~20 | ConfigMarkdown type: Markdown rendering related configuration |
src/config/tui-schema.ts | ~50 | TUI config Schema: Zod definitions for keybindings, themes, layout and other UI configuration |
src/config/tui.ts | ~100 | TUI config logic: loading and processing of TUI-related configuration |
src/config/tui-migrate.ts | ~60 | TUI config migration: automatic migration from old config format to new format |
src/config/console-state.ts | ~20 | ConsoleState type: console output state management |
.opencode/config.json | — | Project-level configuration file (can be committed to Git) |
~/.config/opencode/config.json | — | User 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-4would directly overrideConfig’s model selection at theSessionPromptlayer.
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 Type | Merge Strategy | Example |
|---|---|---|
Simple values (model, temperature) | Latter overrides former | Project model overrides global model |
Dictionaries (provider, mcp, agent) | Deep merge, merged by key | Global provider.openai + project provider.openai → merged |
Arrays (plugins, instructions, command) | Concatenated, not overwritten | Global 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
| Decision | Rationale |
|---|---|
| JSONC instead of YAML | JSONC 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 update | Chose 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-driven | Compared 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 overwrite | Concatenation 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 inheritance | Six 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 search | Searching upward from cwd for configuration files supports running opencode in subdirectories of a monorepo while automatically finding the root configuration |
| catchall defaults to ask | New tool types won’t be accidentally permitted due to missing explicit configuration; safety first |
| InstanceState caching | Configuration 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().providerprovides Provider registration and authentication information - Permission: Permission rules are defined in Config via the
PermissionSchema;Permission.fromConfig()parses the configuration into runtime Rulesets. Config’scatchalldefault ensures safety for new tool types - Agent: Agent behavior parameters (max loop count, default model, temperature, permission overrides) are read from Config’s
agentfield. The Agent list is cached viaInstance.state() - MCP: The MCP server list (
mcpfield) 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
commandarray, 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 onInstance.dispose(). Configuration updates trigger a full instance rebuild