Customize OpenCode v2 for enterprise without replacing the harness
How to restrict and extend stock OpenCode through a pinned v2 seam and a redistribution template: composition slots, managed policy, seeded plugins, and branded packaging.
| OpenCode v2 pin | ravikanchikare/opencode |
| Redistribution template | opencode-enterprise-starter-oss |
| Ownership | Architecture index |
| Distribution | Enterprise setup |
Forking a coding-agent desktop to add a company home page is how the harness gets replaced by accident.
OpenCode already owns the hard runtime: projects, worktrees, sessions, agent execution, terminals, providers, permissions, configuration, and plugin loading. An enterprise still needs a different first-run experience, a narrower model catalog, preinstalled internal tools, and a signed build with its own name. Those requirements do not justify a second session model.
OpenCode Enterprise Starter is the redistribution template for that split. It pins a known OpenCode v2 revision and adds only the seams an organization should own. Anything it does not register stays stock OpenCode.
Keep one harness
| OpenCode continues to own | The enterprise layer can own |
|---|---|
| Projects, workspaces, worktrees, and sessions | Home-page presentation and onboarding |
| Agent execution, PTYs, permissions, and application state | Selected Settings surfaces and organization defaults |
| Provider, model, integration, and configuration machinery | Approved provider and model policy |
| Plugin and skill discovery | Preinstalled plugins, curated skills, and internal workflows |
| Stock desktop behavior and design system | Brand, application identity, packaging, signing, and releases |
Composition slots change selected interface surfaces. Plugins add capabilities through OpenCode’s ordinary extension system. Managed policy narrows the existing catalog. None of those creates a parallel runtime.
Build the seam first
The starter is a composition over a pinned desktop, not a fork of the product. The public composition API lives in the pin. The starter registers surfaces, then boots the stock renderer.
import "./composition.ts"
import "virtual:opencode/desktop-renderer"That import order is part of the contract. Both imports must stay static. The composition registers first; then the pinned renderer starts. A dynamic import of the renderer fails in bundled-dev because the fork entry uses a top-level await.
composition.ts is the place to be narrow. The current example customizes Home utility navigation, adds first-launch onboarding, replaces only the Settings → Providers tab body, and adds an Integrations tab. Every unregistered surface falls back to OpenCode.
A composed surface imports only from virtual:opencode/app-composition and solid-js. It reuses the fork’s settings classes. Local Tailwind in apps/desktop is not emitted. When a surface needs a primitive the pin does not export, widen the pin’s composition API instead of copying host schema classes into the starter.
Restrict the stock catalog
Operator policy is opencode.config.policy, driven by OPENCODE_MANAGED_POLICY. Statements use the same vocabulary as OpenCode configuration: provider.use, provider.model.use, integration.connect. Wildcards work. Last match wins.
Managed statements run after user and project configuration. A repository cannot re-enable a provider the organization denied.
# Deny every provider, then allow the ones procurement approved.
OPENCODE_MANAGED_POLICY='[
{"effect":"deny","action":"provider.use","resource":"*"},
{"effect":"allow","action":"provider.use","resource":"anthropic"},
{"effect":"allow","action":"provider.model.use","resource":"anthropic/*"}
]'Unset or malformed policy is a logged no-op. It does not fail closed and deny every provider, because that would prevent the app from booting. Check the env at distribution time; do not treat an empty policy as an allowlist.
MCP servers stay user configuration. They are not injected through the composition. A denied provider stops the integration it authenticates; a pure tool connection still needs an explicit integration.connect deny.
Extend through plugins, not a second app
Internal tools belong in packages/ as standalone OpenCode v2 plugins. OpenCode discovers them the ordinary way. The starter does not add a service layer or rewrite configuration to load them.
Plugins marked opencode.plugin.seed are bundled into dist/opencode-plugins/ on desktop build. The packaged app installs that set on first launch, so every employee starts from the same approved baseline.
| Plugin | What it demonstrates |
|---|---|
enterprise.capability-demo |
An internal-workflow template: tools, credential-gated actions, skills, commands, a subagent, hooks, cleanup |
enterprise.notion |
Company knowledge as agent tools |
enterprise.usage-telemetry |
Product-usage events over OTLP |
enterprise.skill-source-filter |
Curating skill discovery by excluding unapproved sources |
enterprise.capability-demo is the copy target. Duplicate the package, pick a namespaced plugin id, and replace the example capabilities. Duplicate ids make the whole plugin location fail to activate.
A plugin imports @opencode-ai/plugin for types only. Putting @opencode-ai/* in dependencies installs a second copy of host schema classes and breaks instanceof checks. exports must be a string; the object form is ignored at discovery.
Guarantees that must survive another plugin’s transform belong in the pin, not in a discovered plugin. Domain transforms replay in registration order, and the internal post block always runs last. That is why managed policy can outrank every provider a config document re-adds.
Brand and redistribute
The starter ships unbranded. Stock OpenCode identity remains until apps/desktop/src/brand/brand.mjs is filled in.
export const BRAND = Object.freeze({
displayName: "Acme Code",
appId: "acme-code",
deepLinkScheme: "acme-code",
iconDir: "/absolute/path/to/icons",
update: Object.freeze({ repo: "acme/desktop" }),
})Unset fields keep OpenCode’s name, icons, storage namespace, and update feed. The pinned desktop derives packaging from these values. Do not restate identity in a parallel electron-builder config.
Distribution is the last seam: signed macOS builds, notarization, and an auto-update feed. Unsigned builds can download an update they cannot install. The operational path is in enterprise-setup.md.
What not to build
- A second session, agent loop, provider runtime, or settings store.
- A replacement of the entire Settings surface. The
settingsProvidersslot then stops being read. - Local persistence or seeded MCP servers in the composition entry.
- Direct
@opencode-ai/*runtime dependencies in the starter. - Product code inside
.cache/opencode.
The useful enterprise desktop looks like OpenCode until a specific surface, policy, plugin, or brand value says otherwise.
Takeaways
Pin the harness
Lock ravikanchikare/opencode to an exact v2 SHA. Compose against that contract. Do not vendor a moving tree.
Register the narrowest slot
Change Home and Settings through named composition seams. Leave unregistered surfaces stock.
Policy outranks project config
OPENCODE_MANAGED_POLICY appends after user and repository settings. A repo cannot re-allow a denied provider.
Plugins extend; the pin guarantees
Ship internal workflows as namespaced seeded plugins. Terminal policy and composition APIs belong in the fork.