A shared OpenCode configuration
One git repository for the agents, skills, commands and permissions a team runs on, plus two small plugins: one keeps every checkout current, one keeps MCP tool definitions per repository.

Introduction
A working agent setup is easy to build and hard to hand over. It accumulates in one person’s home directory: an agent prompt here, a permission rule there, a skill that encodes what the team agreed about tests. The second machine gets a copy that is already a week old, the second person gets a screenshot and a list of things to paste, and six weeks later nobody can say which version is current.
What follows is the setup I share with my team. One git repository holds the agents, the permissions, the skills and the commands, and OPENCODE_CONFIG_DIR points every machine at a clone of it. A new laptop and a new colleague are the same operation: clone it, add your own credentials, start working. Each project carries a small contract saying what it is and which MCP servers it needs, and two plugins keep the shared copy current and the tool surface per repository.
The second plugin has a number attached. With every server enabled, roughly a hundred thousand tokens of tool definitions arrive in a session before the first prompt, most of them describing a system the repository in front of you cannot touch. In a shared setup that bill is not paid once: it is paid by every repository, on every machine, by every person.
None of this is a framework. It is a clone, some frontmatter and under three hundred lines of JavaScript and TypeScript, and it is shaped by what broke once the configuration stopped being mine alone.
The repository is the unit
OPENCODE_CONFIG_DIR points OpenCode’s global configuration directory at a clone of one repository, and the clone is the distribution mechanism: the same files serve a laptop, a desktop, and everyone on the team who clones them. Everything at the root then applies to every project on the machine: opencode.jsonc with the agents, providers and MCP definitions, plus the agents/, commands/ and skills/ directories.
That single decision buys the rest of it. A permission change is a diff with a reviewer, and everyone inherits it on the next pull. A new skill is a directory with one file in it, reviewed once. A prompt that made the agent worse is a revert. The alternative — editing files in place on each machine, or letting every project grow its own copy — is how four versions of the same instruction happen; in a team, the four versions belong to four people and nobody notices until the same ticket gets two different results.
opencode-config/ # OPENCODE_CONFIG_DIR
├── opencode.jsonc # agents, MCP definitions, provider, permissions
├── agents/ # seven markdown files
├── commands/ # three prompt templates
├── skills/ # one directory per skill, each with SKILL.md
├── plugins/ # local tool plugins
├── mcp/ # local MCP server source, gitignored
└── docs/mcp.md # how per-repository gating works
What lives where
The shared repository answers “how does the agent behave, for anyone”. The project answers “what is this repository”. Keeping those apart is what makes one clone sufficient: the shared file is the team’s, the repository file is the code’s, the secrets are the person’s.
| Question | Answered in | Changed by |
|---|---|---|
| How should the agent behave, for anyone? | the shared config repository | a reviewed PR |
| What is this repository, and how is it built? | AGENTS.md | a commit with code |
| Which MCP servers does this repository need? | .opencode/mcp.json | a one-line commit |
| Which agents are switched off here? | the project’s own config | rarely |
| What must never leave this machine? | the secrets directory | each person, once |
AGENTS.md is not configuration and is not in the shared repository. It sits with the code, it is reviewed with the code, and it carries the build command, the branch convention and the things a newcomer would get wrong — which makes it the same document an agent and a new joiner read. A configuration repository that tried to hold these files would either be out of date for every project or be enormous.
Shared configuration, private credentials
The repository is shared, so nothing in it can be a credential. Every credential is a file reference — {file:{env:OPENCODE_SECRETS_DIR}/github_pat} in place of a token — resolved at load time from a directory outside the checkout. The property that matters in a team is not only that a diff cannot leak a secret: it is that cloning the configuration grants nobody else’s access. Each person keeps their own tokens, and the shared file never needs to know about them.
The repository is still private, because it names organisations, projects and internal hosts. But the rule stands: a config with a token in it is a config that cannot be shared, which is why the one definition still carrying its credential in a URL is on the list. In a single-person setup that was untidy; in a shared one it is the wrong shape.
Agents are a permission boundary, not a personality
An agent is a markdown file. The frontmatter carries the mode, the permissions and occasionally a temperature; the body is the prompt; the filename is the name. Nothing about the format encourages a persona, which is useful, because the useful part of an agent is what it is not allowed to do — and because the file is identical for everyone who clones the repository.
Seven of them do the work:
| Agent | Mode | Job | Cannot |
|---|---|---|---|
orchestrator | primary | plan the work, route it to a subagent, review the result | edit files, commit, push |
coder | subagent | write, edit, refactor, debug, write the failing test first | commit, push, open a PR |
system-designer | all | patterns, design docs, ADRs, sequence diagrams | run commands, edit source |
code-reviewer | primary | review a pull request, post line comments | edit files |
jira-project-manager | subagent | Jira issues, transitions, comments, worklogs | edit source |
ado-project-manager | subagent | ADO work items, branches, commits, pull requests | edit source |
confluence-docs | subagent | Confluence pages and page hierarchy | edit source |
The roster, and why it is split that way
The split is the design. No agent holds judgement and action at the same time. The orchestrator plans and routes but cannot change a file, so a plan cannot quietly become a patch halfway through. The coder implements but cannot commit, so nothing reaches main except through the agent that runs the commit convention. The project manager owns branches, commits and pull requests but cannot write source. The designer produces the document and cannot execute anything. The reviewer reads a diff and cannot change the code it reviews.
That is worth more than any sentence in a prompt. “Always write the test first” is a preference, and in a team it is a preference each person states differently. A coder whose only route to a commit runs through a different agent, with a different prompt, is a structure the whole team inherits.
The opposite design — one agent with every tool — is not wrong because it is untidy. It is wrong because the two jobs share one context, and a context has no boundary in it. The same window that read a ticket also holds the permission to write the patch, so the line between deciding and doing exists only in the prompt, and the prompt is the part an injected instruction can argue with.
The division of labour also fixes a document you would otherwise have to write. The coder file says test-first for a bug, no comments unless asked, lint and typecheck after the task, ninety per cent coverage. The code-reviewer file says collections are empty rather than null, methods never return null, one class per file, no warnings in the build. Those are house rules, and they live with the role that has to satisfy them rather than in a project wiki nobody loads — which means a new joiner’s coder has them on the first day, not after the first review.
Modes, delegation and the repository’s say
primary is an agent you talk to; subagent is one that gets called and runs in a child session; all is both. The designer is all because I sometimes want it directly and sometimes want the orchestrator to delegate to it. The orchestrator’s prompt is mostly a routing table: code to the coder, design to the designer, tickets to whichever project-management agent this repository has enabled.
When it delegates, the subagent gets its own prompt, its own permissions and a fresh session, and hands back a summary. That is why the rules for a job live in the subagent’s file rather than in the orchestrator’s: the coder never reads the orchestrator’s instructions, and it should not need to.
A repository can switch an agent off, not on. There is no enabled flag, so the orchestrator reads the project config and treats a missing disable: true as enabled. It is a small mechanism that taught me a larger lesson: configuration is a capability declaration, and an agent that assumes a capability instead of reading it will eventually call something that is not there.
Prompts are short on purpose. The designer’s is twenty-five lines: what it owns, which skills it may load, what it never does. Long prompts do not make an agent more reliable; they make it harder to say which sentence caused the behaviour. The volatile detail — a cloud id, the trap in a transition API, the token a page update needs — belongs in a skill that loads when the work needs it, not in a prompt paid for on every turn.
Permissions: the last match wins
Permissions exist at two levels. The shared config sets defaults; an agent’s frontmatter merges over them and wins. Rules are globs, evaluated in order, and the last matching rule decides. That one sentence explains the shape of every block in the repository: catch-all first, exceptions after.
permission:
edit: deny
bash:
"*": ask
ls *: allow
cat *: allow
pwd: allow
git *: deny
git log *: allow
git diff *: allow
git status *: allow
git worktree *: allow
dotnet *: deny
dotnet restore *: allow
Read the git lines together. git *: deny removes the history- and remote-changing verbs; the allows after it restore the read-only ones, because they come later and therefore win. Order is not style here. Moving one line changes the answer.
The block also shows where the permissions, the prompt and the command disagree. The orchestrator’s prompt says it does not run git commands, its permissions allow git worktree, and the /proceed command tells it to create the worktree before delegating. The permission is the tie-breaker: it is what actually happens. Reading the three side by side is how you find out what an agent can really do.
Ask by default, allow the verbs
The orchestrator runs on ask for anything unrecognised, and the recognised commands are the ones that only read. dotnet *: deny with dotnet restore *: allow looks fussy until you watch an agent decide to dotnet clean a worktree it does not own; the restore is the only dotnet verb this role needs. rm *: ask on the coder is the same judgement at a different level: deleting is sometimes the correct move, so it asks rather than refuses.
The task and skill keys are permissions too, and they are the two most people leave at the default. task: deny on the designer means delegation cannot recurse. skill: {"*": deny, "sd-*": allow} does two jobs at once: it refuses a load outside the design family, and it removes those skills from the tool list. Hiding is the half that gets forgotten. A denied skill is not merely refused; it stops being an option the model has to reason about, and that is the same saving the MCP plugin makes one level down.
The gate at the edge of the working directory
external_directory is the rule nobody writes until the workflow stops working. By default, a tool that touches a path outside the working directory asks. In this layout every worktree is a sibling of the canonical checkout, so “outside” is where the work actually lives. Allowing /tmp/** and the projects root, and leaving everything else on ask, is what makes cross-worktree work possible without a prompt every thirty seconds.
It is also the honest shape of the boundary. The agent may move freely inside the garden and must ask at the gate, and the gate is one rule in a reviewed file rather than an assumption about good behaviour.
Where the rules stop
A bash rule matches a parsed command, and a parsed command is not a sandbox. git *: deny stops the commits an agent would otherwise make; it does not stop a script the agent wrote ten minutes earlier, a pipeline, or a command whose arguments were chosen to look like something harmless. I treat these rules as a seatbelt and a routing aid. The containment is that the agent with the dangerous verbs is a different agent, invoked deliberately, with a different prompt.
Two cautions from doing this for a while. A deny rule that names a tool that does not exist never fires and never complains, so a rule worth having is worth testing by attempting the thing it forbids. And a catch-all placed after the exceptions silently overrides all of them, which is why the ordering convention is worth following even when the file looks repetitive.
Skills are routing, not documentation
A skill is a directory containing a SKILL.md. OpenCode lists every available skill in the description of the skill tool — name and description only — and loads the body when the agent asks for it. The repository is mostly skills: fifteen for C# concerns, nine for project management, twenty-five for system design (the twenty-three Gang of Four patterns plus documentation and sequence diagrams), and one for git worktrees. This is where the team’s conventions live rather than in three people’s heads.
The description is the API
Because the listing is everything the agent sees before choosing, the description has to say when to load the skill and, more usefully, when not to. Every sd-pattern-* follows one shape: the pattern’s intent, the situations it fits, and a closing line that names the alternatives — for per-state behaviour, load sd-pattern-state instead. The negative half is what prevents the near miss. Without it, an agent holding twenty-three pattern descriptions picks the one whose name sounds closest to the problem, which is how a Strategy ends up where a State belonged.
The same trick carries the C# skills. Fifteen files named for a concern — controllers, configuration, error handling, repositories, validation attributes, worker hosts, service bus — each naming its folder, the keywords that should trigger it and the sibling to load instead. The agent does not have to infer the boundary from the name, and the ones that pair naturally say so: a service skill points at its repository and error-handling companions, and one ships a worked example beside the SKILL.md.
One skill, one decision
git-worktrees describes the bare-repository layout, the commands that start, finish and clean up a work item, and the rule that worktree commands never run against the base folder. pm-jira-work-logger describes when to assign an issue, how to record a start time and how to log a worklog at the end. Neither restates the codebase; both encode something an agent gets wrong on the first attempt and right on the second.
The work logger is the example I use when explaining why skills are worth the files. Jira’s transition API takes both a transition id and a transition name, and a transition’s name is not the status it leads to. Ask for “In Progress” by name and you are guessing which transition leads there. The skill says: list the transitions, read the status each one targets, use the id that matches. One sentence, and an entire class of error disappears.
Three kinds of text, three different bills
A skill is a procedure the agent loads when the work matches. A command is a prompt a person invokes. AGENTS.md is what is true about this repository. Putting a procedure in AGENTS.md means paying for it on every turn, in every session, whether or not the work needs it. Putting a repository fact in a skill means the agent may never load it.
The test I use: would I want this in the context window of a session that has nothing to do with it? If yes, it is an instruction; if no, it is a skill.
Commands: the handoff you can type
A command is a markdown file with frontmatter: a description, an agent that runs it, and a body that becomes the prompt. There are three, and two of them are the whole reason commands exist here. They are also the part of the setup a teammate notices first: a command is a workflow with a name, not a paragraph copied out of a chat.
/implement WW-7 starts in the system designer. It reads the work item, asks what it needs to ask, produces a plan and stops. It cannot edit a file or run a command, so the plan cannot accidentally become an implementation. /proceed runs as the orchestrator, which creates the worktree, delegates the implementation to the coder, commits through the project manager and reports the manual QA steps.
The value is the handoff. The plan is agreed under one set of permissions, then the work continues under another: the agent that wrote “here is what we will change” could not change anything, and the agent that changes things starts from an agreed plan rather than an improvised one. A person could type “now implement it” and get a similar result, but the frontmatter removes the step where somebody forgets which role they are in — and in a team, it removes the step where two people do it differently.
---
description: Plan the implementation of a work item with the System Designer, then implement with the Orchestrator
agent: system-designer
---
Let's plan the implementation of work item $1 on the Project $2. If $2 has not been provided, infer the project from AGENTS.md.
Once the plan is agreed, we will switch to the Orchestrator agent for implementation in the same session.
Plugin one: the config that pulls itself
A shared repository only helps if every checkout is current, and there are always more checkouts than people: a laptop, a desktop, the machine that runs the headless jobs. They drift for ordinary reasons — one asleep for a week, a change merged from another machine, a branch left checked out — and the drift is invisible until somebody’s agent behaves like last month. The fix is a plugin that runs git pull origin main in the configuration checkout at startup.
function pullConfigRepo(dir) {
if (!dir) return;
const isRepo = spawnSync(
"git",
["-C", dir, "rev-parse", "--is-inside-work-tree"],
{
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
},
);
if (isRepo.error || isRepo.status !== 0 || isRepo.stdout.trim() !== "true")
return;
const pull = spawnSync("git", ["-C", dir, "pull", "origin", "main"], {
stdio: ["ignore", "ignore", "pipe"],
encoding: "utf8",
});
if (pull.error || pull.status !== 0) {
const reason = pull.error
? pull.error.message
: (pull.stderr || "").trim() || `exit code ${pull.status}`;
console.warn(`[config-auto-updater] git pull failed: ${reason}`);
}
}
Four decisions in twenty-five lines. It checks that the directory is a work tree before pulling, so a bare repository, a path inside .git, or an unset variable is skipped rather than producing an error. It never throws: a missing git, a network failure or a merge conflict produces one warning line and startup continues. It uses spawnSync, so it blocks the startup thread for the length of a pull — milliseconds against a local remote, a second against a slow one, and not worth an async dance for something that happens once per start. And it has two entrypoints, because the plugin API changed underneath it: V1 calls server, V2 calls setup, and the V2 half falls back to the standard config directory when the environment variable is unset.
Why a plugin and not a shell alias. Because it has to happen where the agent starts, on every host, whatever shell the person uses, including the desktop app and headless runs. “Remember to pull first” is not a mechanism.
What it does not promise. The pull happens at startup, and anything already read into memory keeps its old value. The honest guarantee is “the next start is current”, not “every change lands live”. And best effort cuts both ways: a conflicted checkout pulls nothing, prints one line into a log nobody reads, and you spend an afternoon debugging a prompt somebody fixed last week. I have not made it loud yet. It is first on the list.
Plugin two: MCP tool definitions are a budget
This is the plugin that earned its place.
The bill
Every MCP server advertises every tool it has, and the host puts those definitions in front of the model on every request. The shared file defines all of them — GitHub, Jira, Azure DevOps, Azure, and a few personal ones — because one definition for every repository is the point of sharing it. With all eight enabled, the repository’s own note puts the cost at roughly a hundred thousand tokens per session.
The context cost is the visible half. A large tool list is also an accuracy problem: a model choosing among hundreds of tools with overlapping descriptions picks the wrong one more often, and a tool defined for a different repository is a confident wrong answer. The argument in MCP servers in production — that tool descriptions are the interface and the advertised surface is a disclosure decision — applies to the harness as much as to the server.
The marker and the mechanism
Each repository carries one file naming the servers it needs:
["jira", "github"]
No marker, or a marker that does not parse, means every server stays off. A name that does not match a configured server is logged and ignored. The file is committed with the code, so “why does this repository need a home automation server?” is a question a reviewer can ask, and the reason a colleague’s frontend repository does not inherit one.
The server definitions live in the shared config with no enablement key at all; the plugin owns enablement completely. On load it registers a synchronous transform that OpenCode replays on every read of the server list. The transform reads the marker and sets disabled on each configured server: on for the names the marker lists, off for everything else. The configuration cannot disagree with the marker, because the marker is consulted on every replay rather than once.
await ctx.mcp.transform((editor) => {
const { names } = readMarker(ctx);
const all = editor.list().map(([name]) => name);
const desired = new Set(names ?? []);
for (const name of all) {
editor.update(name, (server) => {
server.disabled = !desired.has(name);
});
}
});
Editing the marker takes effect without a restart. A poll on the file — one second, cheap — notices a create, edit or delete and fires a debounced mcp.reload() after 250 milliseconds. The watcher also watches the parent directory, because the first thing I do in a new repository is create .opencode/, and a watch on a file that does not exist yet would miss it. The timer does not hold the process open, and the cleanup function stops the watches.
sequenceDiagram
autonumber
actor U as User
participant H as OpenCode host
participant C as Config checkout
participant P as MCP gating plugin
participant M as MCP servers
U->>H: start a session in the repository
H->>C: read shared server definitions
H->>P: load plugin
P->>C: read marker
alt marker valid
P->>H: transform disables every unnamed server
H->>M: connect the named servers
else no marker or invalid JSON
P->>H: transform disables every server
Note over H,M: no MCP tools in this session
end
U->>C: edit .opencode/mcp.json
C-->>P: file changed, one-second poll
P->>H: mcp.reload after a 250 ms debounce
H->>P: replay the transform
H->>M: connect or disconnect to matchTwo details in that flow are easy to get wrong. The marker is read from the project’s canonical checkout before the working directory, so in a linked worktree the canonical copy wins: add a server to the marker on a branch and nothing happens until it merges. That is defensible, since the marker describes the repository rather than the branch, but it is not obvious from the file’s location. And the transform is not a hook: it is a synchronous, replayable edit to the definitions, which is why a reload can reconcile connections without restarting anything.
What changed between V1 and V2
The plugin exists because the ground moved. V1 let a repository add a small config file that marked an MCP server enabled, while the definition stayed in one place. V2 removed that path: an entry that only switches a globally defined server on is ignored during normalisation, with a warning, and a partial {"disabled": false} override is schema-invalid. You either restate a complete, valid server object or you say nothing, and merging replaces server objects wholesale, so there is no field-level layering left to do per repository.
The second change is the extension point. A V1 plugin was a map of hooks. V2 calls setup(ctx) and hands it domains, and ctx.mcp.transform is the documented way to edit server definitions: a synchronous callback replayed over the whole list, with reload() to reconcile connections after the inputs it read have changed. That is what makes a marker file possible. The transform can read a file on every replay and set disabled per server, instead of every repository restating objects to express one line of intent.
The third change is where configuration comes from. On 2.x the repository is the config directory; the old global ~/.config/opencode/opencode.jsonc is not read by 2.x and was archived under legacy-v1/. One file became the single source, which is what made a shared config repository practical in the first place. It also explains a detail in that file: the flat mcp shape still loads, but it silently drops disabled: true during normalisation, so the shared definitions carry no enablement key at all and leave that field entirely to the plugin.
The cost is a V1 habit rebuilt on a V2 extension point, and it only works because V2 shipped a transform API. On a V1 host the plugin is a no-op by design: an empty hook map, and a setup that returns the moment it finds no transform to call. The check is for the capability, not the version number — a version check would have needed editing on every release.
The gap it left
The gap is worth stating plainly. “Fail-closed” describes the marker path: no marker, no servers. It does not describe the plugin-missing path. If the plugin fails to load, nothing sets disabled, and the definitions in the shared file come up enabled — for everyone who pulls it. The plugin’s own README recommends shipping every definition with disabled: true so that the plugin is the only thing that can turn one on. Ours does not, because the transform came first and the seatbelt afterwards. It is one line per server, and it is the change I would make before the next person clones the repository.
The shape underneath
Strip the specifics and both plugins are the same move: keep the state that varies per repository in a small file outside the session, read it at the boundary where it can still change the outcome, and make the default “off”.
Both are safe to run more than once: a pull with nothing to do is a no-op, and the transform replays as often as the host asks. Both fail soft: a plugin that cannot do its job must not stop a session, because the person in front of it has work to do. Both are short enough to read in full before trusting them, which is the only real defence when a plugin runs with your credentials.
Both plugins are public — opencode-mcp-gating and opencode-remote-config-auto-updater-plugin — because they are the reusable part. The configuration repository stays private because it encodes organisation plumbing rather than a technique.
The shared weakness is the same too: silence. A plugin that fails softly fails invisibly, and the failure modes — a stale config, a server you did not ask for — look exactly like a working system until they cost something. The next version of both should say something out loud.
What I would do differently
Ship the deny. disabled: true on every MCP definition, so a missing plugin means no servers rather than all of them. The transform is the mechanism; the default is the seatbelt, and the file ships only one of them.
Make the updater loud. One line in a log is not a notification. A stale config is worse than an error, because an error stops you and a stale config does not.
Keep personal servers out of the shared file. The grocery list and the home automation webhook have no business in a repository a colleague clones. The marker keeps them out of a session, but the definitions are still there, and a shared file should not need to know about them.
Test the onboarding, not the setup. A new machine should be clone, secrets, one variable. If it needs a fourth step, the README is wrong and the fourth step is the one nobody remembers.
Write the marker before the tenth repository. The per-repository contract is two files and five minutes. Retrofitting it means a week of “which servers did this one need?”.
Put constants in one place. A cloud id duplicated into a prompt and two skills is correct today and wrong exactly once, in the copy nobody edits.
Fewer, sharper skills. The ones that aged badly tried to describe a domain; the ones that held up describe a decision. A skill is a procedure with an exit, not a manual.
Closing
None of this is exotic. It is a git repository, seven markdown files with frontmatter, a directory of skills, and under three hundred lines of JavaScript and TypeScript. What it changes is where the decisions live: the agent’s authority is a reviewed diff rather than a habit, the repository declares what it needs in a file you can read, and the tool definitions a session carries are the ones its work requires. And because it is a repository, those decisions are the team’s: one clone on a new machine, one pull for a changed rule.
That is the same argument as the rest of the AI-augmented engineering work: a written contract, a small surface, an audit line. The configuration is not the interesting part. Being able to explain, six months later, why the agent could do that and not this, is — and being able to answer it the same way for everyone who cloned it.