All articles
platform engineering·intermediate·

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.

architecturecoding-agentsopencodepluginssecuritytooling
Resources
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

One harness, two ownership layers
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.

apps/desktop/src/renderer/index.tsxtsx
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.

1
Pin
Lock ravikanchikare/opencode to an exact v2 SHA in opencode.pin.json.
2
Compose
Register only the Home and Settings surfaces the organization must own.
3
Restrict
Append OPENCODE_MANAGED_POLICY after user and project config.
4
Extend
Ship internal workflows as namespaced OpenCode v2 plugins.
5
Distribute
Set brand metadata, sign, notarize, and publish the desktop.

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.

.envbash
# 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.

What the template ships
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.

apps/desktop/src/brand/brand.mjsjavascript
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 settingsProviders slot 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.