OpenCode supports both JSON and JSONC (JSON with Comments) formats for its config file, referenced by the $schema key https://opencode.ai/config.json.[1] Configuration files from all sources are merged together, not replaced: non-conflicting keys from all sources are preserved, and later sources override only conflicting keys.[1] Config sources are loaded in this precedence order (lowest to highest): Remote config (.well-known/opencode), Global config (~/.config/opencode/opencode.json), Custom config (OPENCODE_CONFIG env var), Project config (opencode.json in project root), .opencode directories, Inline config (OPENCODE_CONFIG_CONTENT env var), Managed file config (e.g., /Library/Application Support/opencode/ on macOS), and macOS MDM managed preferences (ai.opencode.managed).[1]
The global OpenCode config lives at ~/.config/opencode/opencode.json; TUI-specific global settings use ~/.config/opencode/tui.json.[1] When OpenCode starts, it looks for a project config (opencode.json) first in the current directory, then traverses up to the nearest Git directory.[1] A custom config file path can be set with the OPENCODE_CONFIG environment variable; it is loaded between global and project configs in precedence order.[1] The .opencode and ~/.config/opencode directories use plural names for subdirectories (agents/, commands/, modes/, plugins/, skills/, tools/, themes/); singular names are also supported for backwards compatibility.[1]
Remote config is fetched automatically from the .well-known/opencode endpoint when authenticating with a supporting provider, and serves as the base config layer that all other sources can override.[1] Organizations can enforce configuration via managed config files dropped in platform-specific system directories: /Library/Application Support/opencode/ (macOS), /etc/opencode/ (Linux), or %ProgramData%\opencode (Windows); these directories require admin/root access to write.[1] The resolved configuration (including managed preferences) can be inspected by running opencode debug config.[1]
The global config file is resolved by checking candidates opencode.jsonc, opencode.json, and config.json (in that order) under Global.Path.config; if none exist, opencode.jsonc is used as the write target.[2] On first load of a global config file that lacks $schema, config.ts automatically injects "$schema": "https://opencode.ai/config.json" and rewrites the file to enable editor completion.[2] The $schema auto-injection is skipped when any of the Flag.OPENCODE_CONFIG, Flag.OPENCODE_CONFIG_DIR, or Flag.OPENCODE_CONFIG_CONTENT flags are set, preventing file writes when config is provided via environment.[2]
loadConfig in config.ts runs ConfigVariable.substitute on raw config text before JSON parsing, expanding well-known variable placeholders in place.[2] loadConfig calls ConfigV2Compat.lower on the raw parsed config before decoding with ConfigV1.Info, providing backward compatibility with v2 config fields in the v1 schema path.[2] Legacy config keys theme, keybinds, and tui are silently stripped from any loaded config object in normalizeLoadedConfig to prevent stale v1 UI config from affecting v2 behavior; the documentation notes these keys are deprecated and automatically migrated when possible.[2][1] When merging config objects, mergeConfigConcatArrays in config.ts concatenates (and deduplicates) the instructions array instead of replacing it, so instructions from multiple config layers accumulate.[2] Remote config URLs are fetched with withTransientReadRetry; if the response Content-Type is HTML or the body matches <!doctype or <html, the fetch is treated as an auth-proxy login redirect and terminates with a RemoteAuthError rather than a decode failure.[2]
plugin_origins is a derived runtime field on Config.Info (not persisted) that tracks, for each winning plugin spec, the file and scope it came from so location-sensitive decisions can be made at runtime.[2] The writable helper in config.ts strips plugin_origins before persisting config, ensuring that derived runtime state is never written back to disk.[2] The writableGlobal helper additionally removes the shell key when its value is an empty string, preventing a blank "shell": "" entry from being written to the global config file when the user resets a value to default in the Desktop app.[2] Config JSON/JSONC patching is performed by patchJsonc in config.ts, which recursively applies jsonc-parser's modify+applyEdits with 2-space indentation rather than a full round-trip serialization, preserving comments and formatting.[2]
The shell used by OpenCode for the interactive terminal and agent tool calls is set via the shell config key; if not specified, OpenCode auto-discovers a sensible default (pwsh or cmd.exe on Windows, /bin/zsh or /bin/bash on macOS/Linux).[1]
When cursor.style is "default" in tui.json, the terminal default cursor is restored and cursor.blinking has no effect.[1]
The Config service (packages/opencode/src/config/config.ts) is composed from sub-modules ConfigAgent, ConfigCommand, ConfigManaged, ConfigParse, ConfigPaths, ConfigPlugin, ConfigVariable, and ConfigV2Compat.[2] The Config.Service class is registered under the Effect context tag "@opencode/Config" and exports a use helper via serviceUse.[2] The Config.Interface exposes get, getGlobal, getConsoleState, update, updateGlobal, invalidate, directories, and waitForDependencies as the public API of the config service.[2] config.ts depends on Auth.Service, Account.Service, Env.Service, Npm.Service, FSUtil.Service, and HttpClient.HttpClient as Effect service dependencies required to construct the Config layer.[2] config.ts notes (in a comment) that remeda's mergeDeep conditional-merge generic dominates TypeScript profiling in hot config-loading paths; the mergeConfig wrapper uses a local cast to keep the expensive type out of critical paths.[2]
The ConfigAgent.load function scans {agent,agents}/**/*.md under a given directory (with dot and symlink support) and parses each file as a markdown-with-frontmatter agent config, deriving the agent name via configEntryNameFromPath stripping agent/ or agents/ prefixes.[3] Each agent config entry has its prompt field set to the trimmed markdown body (md.content.trim()), while frontmatter fields are spread into the config object alongside the derived name.[3] ConfigAgent.load calls ConfigParse.schema(ConfigAgentV1.Info, config, item) to validate and decode each agent entry against the ConfigAgentV1.Info schema, with the file path passed for error reporting.[3] Agent config files that fail markdown parsing (throw during ConfigMarkdown.parse) are silently skipped — the .catch(() => undefined) guard prevents one bad file from aborting the entire load.[3] Mode configs loaded by ConfigAgent.loadMode always have mode: "primary" forced onto the decoded value, regardless of what the frontmatter specifies.[3] ConfigAgent.loadMode uses Schema.decodeUnknownExit with { errors: "all", propertyOrder: "original" } for validation, while ConfigAgent.load delegates to ConfigParse.schema — the two loaders use different validation strategies.[3]
Configure plugins with package strings or { package, options } objects; skills as a discovery-source array; references as a keyed alias map; formatter and lsp as boolean or override records; and attachments/tool_output with limit objects.
{
"plugins": [
"opencode-helicone-session",
{ "package": "@my-org/audit-plugin", "options": { "endpoint": "https://audit.example.com" } }
],
"skills": ["./team-skills", "~/shared-skills", "https://example.com/.well-known/skills/"],
"references": {
"design-system": { "path": "../ui-library" },
"sdk": { "repository": "github.com/example/sdk", "branch": "main" }
},
"formatter": {
"prettier": { "disabled": true },
"project": { "command": ["./scripts/format", "$FILE"], "extensions": [".foo"] }
},
"lsp": {
"typescript": { "disabled": true },
"project": { "command": ["project-language-server", "--stdio"], "extensions": [".foo"] }
},
"attachments": { "image": { "auto_resize": true, "max_width": 2000, "max_height": 2000 } },
"tool_output": { "max_lines": 2000, "max_bytes": 51200 }
}
Sources