OpenCode (opencode)
Set up this tool
OpenCode reads the root AGENTS.md, .opencode/, and opencode.json.
First rule
Install the client using the official setup guide, then open your project in it.
Select opencode in agnostic-ai.yaml, then follow First rule to add the shared commit-prefix rule and sync it. With the default paths, it reaches AGENTS.md.
Generated hooks use the V1 plugin API by default. Select V2 hooks explicitly for OpenCode V2.
For existing files, check what import opencode reads before syncing.
Verify
Run agnostic-ai sync --check and inspect AGENTS.md for the commit-prefix rule. Launch opencode from the project and ask for a commit subject for a small fix; it should use fix:.
Next, add a skill.
Output
AGENTS.md # entry-point pointer body + rules (written by sync)
.opencode/agents/<name>.md # one subagent per agent
.opencode/skills/<name>/SKILL.md # one folder per skill, bundled assets included
.opencode/commands/<name>.md # one per command spec
.opencode/commands/skill-<name>.md # extra command form, only when emit-skills-as-commands: true
.opencode/plugins/<name>.ts # one plugin module per hook spec
opencode.json # when MCP entries exist (merged with user config)
Native mappings
Field mappings, discovery, and compatibility
Rules
OpenCode finds the repo-root AGENTS.md by walking up from the current directory. Codex, Amp, Warp, and the other AGENTS.md tools share the same file, so sync writes it once.
Agents
(docs): .opencode/agents/<name>.md (the singular directory is legacy), with description, mode, model, temperature, permission, and any x-opencode key. The portable maxTurns is written as steps, and x-opencode.steps wins. The body is the system prompt.
- In legacy agent files, a
canortoolslist becomes the agentpermissionmap:can: [read]writespermission: {"*": deny, doom_loop: ask, external_directory: ask, read: {"*": allow, "*.env": ask, "*.env.*": ask, "*.env.example": allow}}. Every tool not listed is denied. The catch-all comes first because the last matching rule wins. - Restricted agents keep project permission decisions for their allowed tools, including
.envprompts, and deny other tools. Sync reads rootopencode.json, including native V2permissionsin order. It does not readopencode.jsonc,.opencode/opencode.json, or global user policy, so agent rules can override restrictions kept only there. - Files with
permissions,request,system, ordisableduse V2 rules, includingdisabled: false. Sync writes generated restrictions as an orderedpermissionsarray and appends explicit native array rules in their source order. These explicit rules can override generated restrictions. OpenCode 2 ignores a top-leveltemperaturein such a file, so sync writes it asrequest: {body: {temperature: ...}}, merged with anyrequestyou set; atemperaturealready inrequest.bodywins. OpenCode 2.2.0 keeps request values but does not yet send them with model requests (agents); this also applies totemperaturein a V1 file. Import preserves the V2 fields and rebuilds the agent without its original native file. - Native
permissionkeys retain source order because the last matching rule wins.x-opencode.permissionoverrides the keys it names; an explicit native action wins outright. Malformed project rules or resource rules that cannot fit the agent format fail sync with the affected paths. - V2 home-relative resources and Windows case differences can make restrictions stricter. Use absolute resources for scoped filesystem grants. Sync rejects grants whose intersection depends on the runtime home. See native permissions for matching semantics.
- These names translate:
Read,EditandWrite(bothedit),Bash,Glob,Grep,Task,Skill,WebFetch,WebSearch, andmcp__<server>__<tool>as<server>_<tool>, and a whole server (mcp:<server>ormcp__<server>) as<server>_*. Other names drop with a sync note. If nothing translates, no map is written, because{"*": deny}alone would lock the agent out.x-opencode: {permission: {...}}wins for each key it names; a single action such aspermission: askwins outright.
Skills
(docs): .opencode/skills/<name>/SKILL.md plus bundled files. OpenCode also scans .claude/skills/ and .agents/skills/. A directory scope from the source folder moves the folder under that directory, and import keeps it.
disable-model-invocation: truekeeps a skill out of the model's available list in OpenCode V2 (verified with 2.0.26). Explicit loading by ID still works. V1 does not enforce this field. Nativemetadata.opencode/autoinvoketakes precedence when both forms are set, and import preserves both.outputs.opencode.emit-skills-as-commands: truealso writes the command form. Its relative links to bundled files point into the skill folder (../skills/<name>/references/x.md). Other links stay as written.- Names must be 1-64 lowercase letters or digits, with single hyphens between segments (the vendor's
namerule, which Zed also enforces).Deploy,my_skill, andmy--skillfail sync with the required format. Sync does not rename them, because OpenCode V1 would skip the folder (V2 does not enforce the pattern).
Commands
.opencode/commands/<name>.md keeps description, agent, model, and subtask.
V1 hooks
One OpenCode plugin module per hook spec. The generated TypeScript module runs the hook command. OpenCode loads every JS or TS file in .opencode/plugins/ at startup.
- Modules follow the vendor's example:
import type { Plugin } from "@opencode-ai/plugin", thenexport const <Name>Plugin: Plugin = async ({ $ }) => { return { ... } }. The type-only import is removed at runtime, so@opencode-ai/pluginis optional. A//comment at the top marks the file as generated. PreToolUseandPostToolUsemap totool.execute.beforeandtool.execute.after. OpenCode's dotted names are kept. Other documented events (session.idle,file.edited,permission.asked, ...) use the singleeventhook with anevent.typeguard.matcherbecomes an anchorednew RegExp("^(?:...)$")tested againstinput.tool, sowritedoes not matchtodowrite. OpenCode tool names are lowercase, so a Claude-styleEditorEdit|Writeis written as is with a sync note.- Claude's
*gets no guard. Neither does a regex that JavaScript reads differently from RE2 ((?i),\A,(?P<name>), and that case adds a note. - Bun's
$runs each command as written. Ontool.execute.before, exit code 2 blocks the call and stderr is the reason. Other failures log a warning, and the next command runs. This includes commands Bun's shell cannot parse (no>&2). disabled: truewrites no module, because OpenCode runs every module in the directory.matcheron an event hook raises a sync note, because event data has no tool name. Anytimeoutraises one too, because OpenCode sets no time limit for handlers.shell.envandexperimental.session.compactingare declined with a note. They rewrite output that a command spec cannot express.import opencodedoes not read.opencode/plugins/back.
MCP
opencode.json in the project root gets a $schema link and the mcp map. The linked schema marks the V1 keys sync writes (permission, mcp.<name>) as deprecated. Sync keeps them because V1 and V2 both read them.
- OpenCode V2 stores servers under
mcp.servers.import opencodekeeps the complete native MCP configuration insettings/opencode-mcp.yaml, underx-opencode.mcp. Sync restores it even without the original JSON file. This import and sync behavior is verified against V2 2.0.26. - Native V2 settings and portable MCP specs cannot both own the MCP configuration. Sync reports the conflicting spec paths. Use one representation for OpenCode.
- Import replaces inline environment and header values with native environment references. It refuses credentials it cannot rewrite, including inline OAuth secrets. Use
{env:NAME}references before importing those fields. - Sync manages each native server separately. Servers and defaults added by hand stay; a removed spec server stays when you have edited it.
- Stdio becomes
{type: "local", command: [...], cwd}, and HTTP or SSE becomes{type: "remote", url, headers}.cwd(relative to the workspace) keeps its name. timeoutkeeps its name and uses milliseconds for MCP requests, including tool calls. OpenCode defaults to30000ms when connecting and fetching tools.disabled: truewrites"enabled": false(MCP docs), and import readsenabled: falseback. An enabled server gets no key.- Other documented fields, such as the
oauthobject for pre-registered remote servers, go throughx-opencode. - Sync changes only
$schemaandmcp.theme,model, and other keys stay. They never show as drift insync --checkordoctor, anddoctor --fixkeeps them.
Settings
A settings spec's default model merges into opencode.json, and import restores it to settings/opencode.yaml. An x-opencode block merges in too. Its permission merges tool by tool with the translated rules.
OpenCode 2.2.0 sets a model's prompt cache lifetime under provider.<id>.models.<id>.settings.cache.ttl (config schema): a number followed by ms, s, m, h, or d. No other target has this setting, so write it under x-opencode.provider:
target: opencode
x-opencode:
provider:
anthropic:
models:
claude-sonnet-4-5:
settings:
cache:
ttl: 1h
Permissions
OpenCode's V2 migration guide distinguishes the defaults: V1 asks before accessing paths outside the project; V2 allows external_directory by default.
To request approval in V2, add the rule below to your existing x-opencode.permissions array, keeping all its rules and their order. If your native permissions array exists only in opencode.json, move the full array into one settings spec under x-opencode.permissions before adding the rule. Only when neither location has a V2 policy, save this settings spec as .agnostic-ai/settings/outside-project.yaml:
targets: [opencode]
x-opencode:
permissions:
- action: external_directory
resource: "*"
effect: ask
Keep the native permissions array in one settings spec: arrays from separate specs replace each other rather than combine. Put this broad ask rule before intentional exceptions. V2 permissions use the last matching rule.
Portable allow, deny, and ask lists become OpenCode's permission map.
- A bare tool name covers the whole tool (
Readbecomesread: allow). A scoped rule becomes a glob (Bash(go test:*)becomesbash: {"go test *": "allow"}). WriteandEditboth land onedit, which covers edit, write, and patch.- If two lists claim one tool and pattern, the stricter wins.
- OpenCode takes the last match, so inside one tool sync writes allow, then ask, then deny, each group with the
*catch-all first. A narrower allow never bypasses a broader deny, as on Claude Code. mcp__<server>__<tool>becomes<server>_<tool>(mcp__github__create_issuebecomesgithub_create_issue: deny), the key OpenCode uses for MCP tools (agents docs). The server name keeps its hyphens.- Scoped
WebFetchorWebSearchrules (those keys take a bare action) and denying a whole MCP server (github_*has no portable spelling) raise a sync note. x-opencode.permissionwrites OpenCode's own map and replaces the translated rules for that tool.- Sync owns only the tool keys your specs produce. A key you wrote in
permissionthat no spec sets, such as"edit": "ask", stays, and a bare"permission": "ask"stays as the"*"entry. A tool key leaves with its spec while you have not edited it. When a spec starts setting a key you wrote, sync saves the file asopencode.json.bakfirst. - A project synced before this change has one record for the whole
permissionvalue. If you edit another key in the same sync where a spec drops a tool, that tool stays as yours, since sync kept only a checksum of what it wrote. import opencodereads the map back, except namespaced MCP keys, where the server name cannot be told from the tool name.
Old .opencode/AGENTS.md leftovers
Sync removes a generated file at the old .opencode/AGENTS.md path, which OpenCode never read. It leaves a hand-written one.
OpenCode V2 hooks
Set x-opencode.plugin-api: v2 on each hook for OpenCode V2. This route is verified against 2.0.26. Omitting it, or choosing v1, keeps the V1 plugin format. Other values fail sync with the source path.
name: guard-shell
targets: [opencode]
event: PreToolUse
matcher: shell
command: .agnostic-ai/scripts/guard-shell.sh
x-opencode:
plugin-api: v2
On macOS and Linux, V2 supports PreToolUse, PostToolUse, tool.execute.before, and tool.execute.after. Other events fail sync. Use these native event names; OpenCode has no portable on mapping. Explicit matchers stay anchored JavaScript regular expressions: bash is not rewritten to shell. An invalid expression prevents the plugin from loading.
The plugin registers a native V2 tool callback and runs commands in the triggering session's checkout. It sends the native event as one JSON object plus a newline on stdin. Before-tool input includes tool, sessionID, agent, messageID, id, and input. After-tool input adds status and either result or error. PostToolUse runs only for completed tools; the native tool.execute.after event also runs for failed tools.
Commands run in order. A before-tool command exiting 2 blocks the operation and stops later commands; stderr gives the reason. Other command failures log the hook name, command, and error, then continue. Stdout is ignored. Structured stdout decisions are not supported by this route.
With args, the executable and each argument are passed separately. Without args, the command runs through /bin/sh. Exec-form scripts must be executable. Shared scripts are copied to .opencode/hooks/ as with V1.
On Windows, use OpenCode V1 with the default V1 hooks. OpenCode V2 cannot run V1 plugins. V2 plugins load with a clear warning and refuse matching callbacks before reading session data or starting a command. A before-tool callback blocks the operation. An after-tool callback reports the refusal after the operation has already run.
timeout is a positive whole number of seconds, up to 86400; the default is 60 seconds. A timeout stops the owned process tree and logs a failure. Unloading the plugin also stops active commands and removes its callbacks. x-opencode.env adds string values to each command's inherited environment. Each invocation receives AGNOSTIC_AI_TARGET=opencode.
Sync writes .opencode/plugins/<name>.ts. The type-only @opencode/plugin import adds no runtime dependency. Arbitrary plugin code is not imported back into specs.
Config keys
| Key | Default | Notes |
|---|---|---|
outputs.opencode.agents-dir | .opencode/agents | |
outputs.opencode.skills-dir | .opencode/skills | |
outputs.opencode.commands-dir | .opencode/commands | |
outputs.opencode.hooks-dir | .opencode/plugins | OpenCode only loads plugins from its own directory, so moving this stops the hooks loading |
outputs.opencode.mcp-file | opencode.json | |
outputs.opencode.emit-skills-as-commands | false | |
outputs.opencode.rules-file | unset | writes legacy joined rules and skips the write of instructions that point to source specs |
With builtins: [memory], sync adds both shared memory indexes to instructions and keeps your own entries. Dropping the built-in removes only those two.
With memory.personal: repo, sync also adds "<folder>/**": "allow" to permission.external_directory, so OpenCode saves to the personal memory folder without asking. Your own entries stay in the order you wrote them, and the folder entry goes last when sync first adds it, since OpenCode applies the last rule that matches. A bare action you set there, such as "deny", becomes the "*" entry. A bare "permission": "deny" becomes {"*": "deny"}, which means the same and stays after repo mode ends. Once sync has added the folder entry it stays where it is, so a broader rule you put after it keeps winning, and an entry you edit stays yours. A folder path with * or ?, which OpenCode reads as wildcards, gets no external_directory entry and a note; move AGNOSTIC_AI_HOME or set memory.personal: checkout.
With handoff or handoff-hook enabled in a linked worktree, sync also allows the main checkout's ignored handoff store under permission.external_directory. Existing memory and user permission rules stay, and a rule you or a spec wrote for that path, such as deny or ask, is never replaced. A main checkout path with * or ? gets no grant. For a tracked, hand-written, or unmanaged file sync skips only this grant and reports the permission to add by hand (once you add it, it stays); the rest of its output is unchanged.
Import
agnostic-ai import opencode reads:
- Instructions and rules: root
AGENTS.md, falling back to legacy.opencode/AGENTS.mdwhen the root file is absent. The shared body becomes.agnostic-ai/AGNOSTIC_AI.md. Rule sections that sync wrote become rule specs. Hand-written instructions stay whole. - Agents and commands: top-level Markdown files in
.opencode/agents/and.opencode/commands/. - Skills:
.opencode/skills/,.claude/skills/, then.agents/skills/(first wins within a scope), with scoped paths and bundled files. - MCP servers: the
mcpmap inopencode.json. - Settings and permissions: the default
modelbecomessettings/opencode.yaml; portable entries in thepermissionmap becomesettings/permissions-opencode.yaml. Namespaced MCP permission keys have no portable spelling and stay in the file. - Providers: the
providerblock, such as a model's cache lifetime, becomessettings/opencode-provider.yamlunderx-opencode.provider.{env:NAME}and{file:path}references import as written. A block that holds a literal credential, such as an inlineapiKey, is left out with a warning and stays inopencode.json. Import fails when another settings spec sets a differentx-opencode.provider.
Plugin hooks in .opencode/plugins/ are not imported.
Protected paths
Not enforced. Sync cannot write an edit-blocking rule or hook for this tool, so it prints a protected-path coverage note.