12 — Porting a Claude Code workspace to OpenCode
What this page is for. Taking a project a Claude Code session already works in, and making an
OpenCode session a first-class participant in the same workspace — same instructions, same MCP
servers, same fleet mount.
The short version: there is almost nothing to port. OpenCode reads CLAUDE.md natively. The
only artifact you create is one opencode.json mapping MCP servers. Everything below was verified
against opencode 1.18.16; the procedural form ships as the port-to-opencode skill.
What you get for free
OpenCode's instruction search order:
1. walking up from cwd: AGENTS.md , then CLAUDE.md
2. global: ~/.config/opencode/AGENTS.md
3. Claude Code global: ~/.claude/CLAUDE.md (unless disabled)
"The first matching file wins in each category."
So a project CLAUDE.md and your user-level ~/.claude/CLAUDE.md are already read. No
translation step, no generated second copy, no sync tool — and therefore none of the drift that
comes with them.
flowchart LR
subgraph W["Claude Code workspace"]
C1["CLAUDE.md"]
C2[".mcp.json"]
C3[".claude/skills/"]
end
subgraph O["OpenCode"]
O1["read natively"]
O2["opencode.json<br/>(mcp)"]
O3["not read"]
end
C1 -->|"no port needed"| O1
C2 -->|"map — FILTERED"| O2
C3 -.->|"no equivalent"| O3
classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
class O3 warn
Instructions cross for free; MCP servers need a small mapping; skills do not cross at all.
The one file you write
Project config is opencode.json in the repo root; the global one is
~/.config/opencode/opencode.json. Configs merge rather than replace, so machine-local servers
belong in the global file and shared ones in the project file.
.mcp.json |
opencode.json |
|---|---|
"type": "http" / "sse" |
"type": "remote", "url" |
"type": "stdio" |
"type": "local", "command": ["bin", "arg"] |
"command" + "args" |
single "command" array |
"env" |
"environment" |
"headers" |
"headers" |
{
"$schema": "https://opencode.ai/config.json",
"instructions": ["CLAUDE.md"],
"mcp": {
"fleet": { "type": "remote", "url": "http://127.0.0.1:8765/mcp", "enabled": true }
}
}
The mount name here must be fleet, not fleetd. Every launcher writes this same constant name
(PeerLauncher.MCP_MOUNT_NAME = "fleet", PeerLauncher.java:34) into the config it builds for a
spawned worker — ClaudeCodeLauncher into an inline --mcp-config (ClaudeCodeLauncher.java:348),
OpenCodeLauncher into the opencode.json it writes (OpenCodeLauncher.java:345). A hand-written
config using a different key mounts a server under a name the role-detection ladder in CLAUDE.md
does not check, so an opencode peer set up by hand must match this name exactly.
Set instructions explicitly even though CLAUDE.md is found anyway — the fallback applies only
while no AGENTS.md exists.
Secrets: reference, never embed
OpenCode substitutes at load time, in both headers and environment:
{env:VARIABLE_NAME} from the environment
{file:~/.secrets/token} from a file
This is what makes opencode.json safe to commit — and it must be committable, because a peer
running in a git worktree receives tracked files only. No credential ever belongs in the file.
The trap: {env:…} reads OpenCode's process environment, and OpenCode has no env store of its
own. Claude Code supplies this project's secrets through env blocks in its settings cascade —
files opencode never reads. Verified here: a clean login shell
(env -u VAR zsh -lc …) has none of CONTEXT7_TOKEN, GITEA_ACCESS_TOKEN, GITEA_HOST; they
exist only inside processes Claude Code spawned. Run opencode mcp list from such a process and
everything is green; run it from a real terminal and context7 is ⚠ needs authentication.
Worse, the two launch paths disagree on names. A fleetd-spawned worker gets GITEA_TOKEN +
GITEA_HOST from applyGitToken (see Features → the git-forge token grant; the
method lives at HerdrPeerLauncher.java:893-900 and both launchers call it), not
GITEA_ACCESS_TOKEN — so an opencode.json written against the terminal's environment fails
inside a worker, and vice versa.
So the supply route follows from who launches opencode:
| Launcher | Route | Here |
|---|---|---|
| a human, from a terminal | {file:.secrets/…} |
gitignored .secrets/, workspace-scoped |
| a fleetd-spawned worker | {env:…} |
applyGitToken + the profile's env: block |
For the terminal case this repo uses {file:} with workspace-relative paths — verified: opencode
resolves them against the project root, so no shell export and no direnv is needed, and the secret
never enters an rc file that every process inherits.
.secrets/ being gitignored is the point and the limitation: a worker in a git worktree does not
receive it, by the same tracked-files-only rule that makes opencode.json worth committing. Workers
are fed by the launcher instead — which is also where the name mismatch above must be reconciled.
Keeping Claude Code on the same source of truth
Claude Code expands ${VAR} in .mcp.json but cannot read a file, so left alone it needs the same
tokens duplicated as literals in .claude/settings.local.json. A committed .autoenv (directory-scoped
env loader — autoenv here, direnv elsewhere) closes that gap by exporting .secrets/ into the
environment, holding no secret of its own:
flowchart LR
S[".secrets/<br/>(gitignored, 0600)"]
S -->|"{file:...}"| OC["opencode.json"]
S -->|".autoenv exports"| E["process env"]
E -->|"${VAR}"| MJ[".mcp.json"]
L["launcher env"] -->|"{env:...}"| WK["spawned worker"]
classDef warn fill:#b7791f,stroke:#7b341e,color:#ffffff;
class L warn
One store, three readers — the worker path stays separate because a worktree has no .secrets/.
Two verified caveats. The loader fires on entering the directory and when a shell starts there,
so a terminal opened directly in the repo is covered — but a Claude Code launched outside a login
shell is not, and an unset ${VAR} degrades silently to the literal text ${VAR} rather than
failing. And leaving the directory does not unload the variables unless leave-files are enabled.
What must never cross
Machine-local MCP servers — an IDE index, a language server, an editor bridge. A committed project config reaches every worktree, and a worker mounting servers whose paths point into the primary's checkout will edit the primary's files while building in its own: every build passes, every change lands in the wrong tree. See Features → Worker tool-surface isolation.
Keep those in the global config if you want them personally.
Verify against the running agent
A file on disk proves nothing about what the agent loaded.
opencode run "In one line: state a rule from this project's instructions."
The answer must reflect the real CLAUDE.md. Then opencode mcp list — and treat its verdict with
suspicion: "connected" does not mean "working". A stdio server missing its credential completes
the handshake and reports green; only a tool call exposes it. Verified: gitea showed
✓ connected with no token, then failed the first call with token is required. Exercise one
authenticated tool per server, and confirm nothing you withheld appears.
Gotchas
- A leftover
AGENTS.mdsilently shadowsCLAUDE.md. If one exists from an earlier port, delete it, or opencode reads the stale copy instead of the live rules. Check this first. - Skills do not port. OpenCode uses its own agent markdown under
.opencode/agents/(HerdrPeerLauncher.java:322-326builds that path for both launchers);.claude/skills/**is not read. A delegation brief that says "load the implementer skill" has no effect on an opencode peer — spell the procedure out in the brief instead. - Identity comes from the terminal, not the runtime. A peer must be spawned into its own pane;
running
opencodein the primary's pane resolves as the primary. - The subscription guard doesn't apply. It is
ANTHROPIC_BASE_URL-shaped and lives only inClaudeCodeLauncher;OpenCodeLauncher.buildLaunchnever calls it. opencode authenticates through its own credentials, somaxLoadis the only spend control that reaches it.
Appendix — the Codex port (superseded)
Chapter 12 previously documented porting to OpenAI Codex. That path was abandoned in favour of
opencode, which fleetd already supports through OpenCodeLauncher. Four findings are kept because
they were verified by hand and explain why the comparison went this way:
- Codex has no
CLAUDE.mdfallback. Instructions had to be translated intoAGENTS.mdby a third-party tool, whose terminology map rewrote file paths into sentences that were wrong rather than merely awkward — turning "never commit.mcp.json" into a rule about a path in~/that cannot be committed at all. The generated file was never safe to regenerate. - Codex config has no variable interpolation.
${VAR}passes through literally, so a secret in a config file had to be a literal value. - Codex scrubs the environment for MCP servers. A stdio MCP child receives only a core set
(
HOME LANG LOGNAME PATH PWD SHELL SHLVL TERM TMPDIR USER …) — neither the parent environment norshell_environment_policy.setreaches it. A per-serverenv_varswhitelist exists to pass named variables through; it was documented and accepted by the CLI, but never confirmed end-to-end here. - Project config is gated on trust.
.codex/config.tomlis read only when the checkout is markedtrust_level = "trusted"in the home config, and the two layers merge.
Contrast with opencode's {env:VAR} and native CLAUDE.md reading: the same three problems — rules
delivery, secret handling, and tool-surface control — cost one small JSON file instead of an adapter,
a provisioner, and a translation tool.
📖 fleet
Home — overview & the decision
Chapters
- Architecture — system · 2 invariants · 2 modes
- Message Server — the
fleetddesign - Approaches — transports compared, why herdr
- Setup — ⚫ superseded by 13
- Operations — ⚫ superseded by 13
- Team — orchestrating a mixed fleet
- Use Cases — the review scenario + mechanisms
- Roadmap — delivery record: what is live, what is off, what was dropped
- Implementation — as-built code map · classes · flows · state machines
- Cross-Host Messaging — broker topology · exchanges · queues per entity
- Features — what it can do · the knob that turns it on · why · the gotcha
- Claude → OpenCode — porting a workspace to a second host
- User Guide — 🟢 install · configure · run · delegate · the traps
- Fleet Manager — many fleets on one host, over REST
- REST API Reference — all 14 routes, roles, and bodies
- Security & Trust Boundary — the guard · authz · what a member inherits
Design proposals (not built)
- CB-548 Lead Quorum — a deterministic decision procedure around a lead's judgment
🟢 herdr-centric fleetd · AgentAPI = research, never built