All posts

OpenCode Reloaded

OpenCode Reloaded

Click a letter, or drag across a row to select letters, then type to replace them. Arrows move between letters and rows; Shift extends a row selection. Delete erases. Alt up or down cycles characters; Alt left or right rotates a selection like a conveyor. Escape releases the cursor. Double-click empty space to replay.

Let’s discuss the subtle pleasures of hot reloading. It spares us from the tedium of minor exertions, of hitting when we could have otherwise remained blissfully inert. To the human mind, the gulf between even the most negligible of impediments and none whatsoever is infinite.

Thus, in OpenCode 2, we’ve gone through the trouble of making all reloading as unbearably hot as possible.

OpenCode can edit its config, connect an MCP server, or write a plugin, and any changes will immediately take effect across every session. It can make a tool for itself and invoke it the very same turnwithout needing to quit, type /reload, or start a new session, and certainly without .

Tools3

read
write
shell

Skills2

Session
Files
.opencode
plugins
writing.ts
skills
grill-me
SKILL.md
the-post.md

The Problem

OpenCode’s environment (its models, tools, agents, skills, etc.) is assembled by plugins. Much of its default behavior lives in built-in plugins. And you, dear reader, can install additional plugins, write your own, or ask OpenCode to write one for you.

For instance, a built-in plugin populates the model catalog with data from models.dev. While OpenCode is running, it periodically refreshes that data to keep up with the ever-increasing rate of model releases.

Model
Context
Output
Cost
State
OpenAI
GPT-5.6
1.05M
128K
4 / 20
GPT-4o
128K
16.38K
2.5 / 10
GPT-5.5
1.05M
128K
5 / 30
Anthropic
Claude Opus 5
1M
128K
5 / 25
Claude Sonnet 4.6
1M
128K
3 / 15
Claude Haiku 4.5
200K
64K
1 / 5
DeepSeek
DeepSeek V4 Pro
1M
384K
0.435 / 0.87
DeepSeek V4 Flash
1M
384K
0.14 / 0.28
Google
Gemini 2.5 Flash
1.05M
65.54K
0.3 / 2.5
Gemini 2.5 Flash-Lite
1.05M
65.54K
0.1 / 0.4
Mistral
Mistral Large
262.14K
262.14K
0.5 / 1.5

How do several plugins coordinate modifying the same catalog? Let’s start with a naive implementation and refine it together as it fails us in one way or another.

For our first attempt, we’ll give plugins direct access to a shared model catalog through the ctx argument every plugin receives. They can reach in and rearrange it as they see fit: adding models, replacing them, or changing their settings.

models-dev.ts
export async function modelsDev(ctx) {
  const load = async () => {
    const providers = await fetchModelsDev() 

    for (const provider of Object.values(providers)) {
      ctx.catalog[provider.id] = provider 
    }
  }

  await load()
  setInterval(load, 60 * 60 * 1000) 
}

It fetches all the latest provider data from models.dev and writes it into the model catalog . This happens at startup and once an hour thereafter .

Alas, there’s already a bug. If models.dev removes a provider, it’ll remain in our catalog forever because we don’t clean up missing entries. But before we fix this, let’s make things worse.

Here’s another plugin that modifies the catalog. This one disables models from certain providers, perhaps to align with some boring corporate policy.

provider-policy.ts
export async function providerPolicy(ctx) {
  const excludedProviders = new Set(["perinium", "sphinctral"])

  for (const provider of Object.values(ctx.catalog)) {
    if (!excludedProviders.has(provider.id)) continue

    for (const model of Object.values(provider.models)) {
      model.disabled = true
    }
  }
}

On startup, these run in order. First, the models.dev plugin fills the catalog; then our policy plugin disables the restricted models. So far, so good.

However, when the refresh timer fires, the models.dev plugin completely replaces those provider records with fresh values, inadvertently undoing everything our policy plugin did.

models.dev
models-dev.ts
60:00
provider-policy.ts
Model
0 available
An empty catalog

To deal with this, let’s have the models.dev plugin announce each refresh, and have our policy plugin listen for that announcement and disable the models again after each one.

provider-policy.ts
export async function providerPolicy(ctx) {
  await ctx.event.on("catalog.updated", () => {
    disableProviders(ctx.catalog)
  })

  disableProviders(ctx.catalog)
}

function disableProviders(catalog) {
  const excludedProviders = new Set(["perinium", "sphinctral"])

  for (const provider of Object.values(catalog)) {
    if (!excludedProviders.has(provider.id)) continue

    for (const model of Object.values(provider.models)) {
      model.disabled = true
    }
  }
}

This is getting complicated. We now have a background process changing the catalog and another plugin responding to those changes. We also have to make sure nobody reads the fresh catalog before our policy has run.

Even if we sort all that out, we only got away with re-running our policy because disabling an already-disabled model does nothing. It’s idempotent. Consider instead a plugin which halves each model’s output limit, the maximum number of tokens a model may produce in one reply.

limits.ts
export async function limits(ctx) {
  await ctx.event.on("catalog.updated", () => {
    halveLimits(ctx.catalog)
  })

  halveLimits(ctx.catalog)
}

function halveLimits(catalog) {
  for (const provider of Object.values(catalog)) {
    for (const model of Object.values(provider.models)) {
      model.limit.output /= 2
    }
  }
}

Suppose another plugin, local-model.ts, adds a couple of models that run on your own machine. Following our policy plugin’s lead, we re-run halveLimits after each catalog update to catch the additions, but it also halves the models we’ve already handled. Their limits are now a quarter of what they started with.

models-dev.ts
limits.ts
local-model.ts
Model
Output

If we continue down this path, every plugin has to keep track of what every other plugin has done. It would become an inextricable clusterfuck.

These bugs have the same cause. Plugins are mutating a shared catalog in place, so the result depends on how many times each one has run and in what order. Let’s see how we addressed that in OpenCode.

The Solution

Instead of wrestling with this tangled mass of imperativity, wouldn’t it be lovely if a plugin could describe its change, and let OpenCode decide when to apply it?

That’s what we did. Each plugin hands OpenCode a catalog transformation function; OpenCode keeps them in order and threads an empty catalog through each one in turn.

models-dev.ts
local-model.ts
provider-policy.ts
limits.ts
Model
Output
OpenAI
GPT-6 Astra
128K
GPT-5.6 Sol
128K
Sphinctral
Squeeze 3.5
128K
Squeeze 3.5 Fast
128K
Local
Couch Potato 8B
128K
Pocket Goblin 14B
128K
OpenAI
GPT-6 Astra
128K
GPT-5.6 Sol
128K
Sphinctral
Squeeze 3.5
128K
Squeeze 3.5 Fast
128K
Local
Couch Potato 8B
128K
Pocket Goblin 14B
128K

The catalog starts empty. models-dev.ts fills in OpenAI and Sphinctral, local-model.ts adds our two local models, provider-policy.ts disables Sphinctral, and limits.ts halves every output limit, 128K to 64K. And out pops the final catalog.

Each plugin is now only responsible for its own changes, while OpenCode takes care of when and in what order to run them. Here is the reworked provider-policy.ts. Notice that ctx.catalog is no longer the catalog itself but a handle to it, and the plugin registers its model-disabling operation as a transformation:

provider-policy.ts
export async function providerPolicy(ctx) {
  const excludedProviders = new Set(["perinium", "sphinctral"])

  await ctx.catalog.transform(catalog => {
    for (const provider of Object.values(catalog)) {
      if (!excludedProviders.has(provider.id)) continue

      for (const model of Object.values(provider.models)) {
        model.disabled = true
      }
    }
  })
}

Reloading

Before, plugins had free rein to mutate the catalog whenever and however they pleased. Now they register transformations up front. But with this architecture, how would the models.dev plugin update the catalog on its hourly cadence?

It calls ctx.catalog.reload(), which creates a new empty catalog, pipes it through the various transformations, and then publishes the result. Behold

models-dev.ts
export async function modelsDev(ctx) {
  let providers = await fetchModelsDev() 

  await ctx.catalog.transform(catalog => { 
    for (const provider of Object.values(providers)) {
      catalog[provider.id] = structuredClone(provider)
    }
  })

  setInterval(async () => {
    providers = await fetchModelsDev() 
    await ctx.catalog.reload() 
  }, 60 * 60 * 1000)
}

The plugin fetches the provider data and registers a transformation that closes over it . Every hour it fetches again, replacing the variable , and calls ctx.catalog.reload() . The transformation itself never changes, but it reads providers when it runs, so the next rebuild picks up whatever was fetched last.

models-dev.ts
local-model.ts
provider-policy.ts
limits.ts
Model
Output
OpenAI
GPT-6 Astra
64K
GPT-5.6 Sol
64K
Sphinctral
Squeeze 3.5
64K
Squeeze 3.5 Fast
64K
Local
Couch Potato 8B
64K
Pocket Goblin 14B
64K
OpenAI
GPT-6 Astra
128K
GPT-5.6 Sol
128K
Sphinctral
Squeeze 3.5
128K
Squeeze 3.5 Fast
128K
Local
Couch Potato 8B
128K
Pocket Goblin 14B
128K

Because every rebuild starts from an empty catalog and runs every transformation exactly once, in order, the refresh can no longer undo the policy, a provider that disappears from models.dev disappears from the catalog with it, and halving a limit halves it once.

Adding, editing and removing plugins

Beyond reloading within plugins, such as our catalog refresh example, we must also respond immediately to plugin files themselves being created, edited, or deleted. This is but a thin layer atop the State abstraction.

When a new plugin is written to the filesystem, OpenCode runs it and keeps track of each registered transformation, associating it with that plugin. The file watching is done by another built-in plugin.

Later, if that file is deleted, everything it registered is dropped, and the affected State is rebuilt without it. Editing a plugin is deletion followed by a fresh run: the old version’s transformations are dropped, the new version runs and registers its own, and the rebuild picks those up instead. Either way, every session sees the result.

git-tools.ts
search.ts
ToolSource
read
core
write
core
shell
core
git
git-tools
search
search
read
core
write
core
shell
core
git
git-tools
search
search
weather
weather
read
core
write
core
shell
core
git
git-tools
search
search

The End

So, that’s it. The core idea is to register and play back transformation functions. It’s simple and reliable because, given the same starting state and the same data, running the same sequence of transformations always produces the same result. Config, MCP servers, and plugins all reload the same way, because they’re all States.

That’s how it all works, with a few details elided. OpenCode is, of course, fully open source, if you want the rest.