Skip to content

Why OpenCode's Codebase Is So Clean

· 10 min read

I have been using OpenCode daily for months. I wrote plugins, custom tools, and built an entire workflow around it, but I never actually read the source code. People keep saying it is one of the cleanest codebases out there. I kept hearing it from other devs, seeing it in threads, and I finally decided to clone the repo and look for myself.

They are right, and most of the patterns that make it clean have nothing to do with AI. They are general architecture decisions that would make any TypeScript project better. I want to walk through the ones I think are worth stealing.

Decide once, apply everywhere

The single biggest thing OpenCode gets right is that every domain module follows the exact same shape. Session, Agent, Provider, Bus, Config, Skill, Permission, Plugin all look like this:

export namespace Agent {
  export const Info = z.object({
    name: z.string(),
    mode: z.enum(["subagent", "primary", "all"]),
    permission: Permission.Ruleset,
    prompt: z.string().optional(),
    // ...
  })
  export type Info = z.infer<typeof Info>

  export interface Interface {
    readonly get: (agent: string) => Effect.Effect<Agent.Info>
    readonly list: () => Effect.Effect<Agent.Info[]>
    readonly defaultAgent: () => Effect.Effect<string>
  }

  export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}

  export const layer = Layer.effect(Service, Effect.gen(function* () {
    const config = yield* Config.Service
    const auth = yield* Auth.Service
    // wire up, return Service.of({...})
  }))

  export const defaultLayer = layer.pipe(
    Layer.provide(Config.defaultLayer),
    Layer.provide(Auth.defaultLayer),
  )

  const { runPromise } = makeRuntime(Service, defaultLayer)

  export async function get(agent: string) {
    return runPromise((svc) => svc.get(agent))
  }
}

Namespace, Zod schema, interface, service class, layer, default layer, thin async wrappers. Every single module. The pattern removes an entire category of decisions from the project. Nobody debates whether something should be a class or a function or a factory. Nobody invents a new way to wire dependencies because the way already exists. The result is that you can open any file in packages/opencode/src/ and know immediately where everything lives.

The general principle is worth applying even if you are not using Effect or namespaces. Pick a module shape for your project and enforce it. Write a template. Make it the default. Most architecture problems come from inconsistency, not from picking the wrong pattern.

Adopt powerful tools, then constrain them

They use Effect heavily, but they only use it for one thing: dependency injection and lifecycle management. ServiceMap.Service defines the contract. Layer.effect wires the implementation. Layer.provide composes dependencies. That is it. No monadic pipelines threading through business logic, no gratuitous stream composition where a simple function would work.

The entire bridge between Effect and the rest of the codebase is 33 lines:

export function makeRuntime<I, S, E>(
  service: ServiceMap.Service<I, S>,
  layer: Layer.Layer<I, E>,
) {
  let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
  const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))

  return {
    runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
      getRuntime().runSync(attach(service.use(fn))),
    runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
      getRuntime().runPromise(attach(service.use(fn)), options),
    runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
      getRuntime().runFork(attach(service.use(fn))),
    runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
      getRuntime().runCallback(attach(service.use(fn))),
  }
}

Every module calls makeRuntime and gets back typed runners. The rest of the codebase calls Bus.publish() or Agent.get() like normal async functions. The callers never touch Effect directly.

This applies to any powerful library. ORMs, state machines, reactive frameworks, GraphQL resolvers: adopt them for the specific problem they solve, put a thin wrapper around them, and do not let them leak into every file. The moment your entire codebase needs to understand a dependency to work on it, that dependency owns you.

Make the dependency graph readable

The defaultLayer on every module makes its dependencies explicit:

// Agent depends on Config, Auth, Skill, and Provider
export const defaultLayer = layer.pipe(
  Layer.provide(Provider.defaultLayer),
  Layer.provide(Auth.defaultLayer),
  Layer.provide(Config.defaultLayer),
  Layer.provide(Skill.defaultLayer),
)

// ToolRegistry depends on Config and Plugin
export const defaultLayer = Layer.unwrap(
  Effect.sync(() => layer.pipe(
    Layer.provide(Config.defaultLayer),
    Layer.provide(Plugin.defaultLayer),
  )),
)

You can open any module and see exactly what it depends on. No hidden global state, no singletons initialized at import time, no circular imports where module A needs module B which needs module A.

Most projects bury their dependency graph in import statements scattered across dozens of files. The graph still exists, but nobody can see it. Making it explicit (whether through Effect layers, a DI container, or even just a createModule() factory that takes its dependencies as arguments) means you can reason about what breaks when something changes.

Things that change together should live together

OpenCode has no types/ folder. No events/ folder. No schemas/ folder. Each module defines everything it needs in its own directory.

WhatWhere it lives
Zod schemasSame file as the logic (agent.ts defines Agent.Info)
SQL tablesColocated .sql.ts files (session.sql.ts next to session/index.ts)
Event definitionsInline in the owning module
Branded ID typesschema.ts in each domain directory
Tool descriptions.txt files next to the tool .ts file

Events are the clearest example. The permission module defines its own events:

// permission/index.ts
export const Event = {
  Asked: BusEvent.define("permission.asked", Request),
  Replied: BusEvent.define("permission.replied", z.object({
    sessionID: SessionID.zod,
    requestID: PermissionID.zod,
    reply: Reply,
  })),
}

And the server module defines its own:

// server/event.ts
export const Event = {
  Connected: BusEvent.define("server.connected", z.object({})),
  Disposed: BusEvent.define("global.disposed", z.object({})),
}

The alternative is a shared events.ts that every module imports. That file becomes a bottleneck: every change to any event touches the same file, every module depends on every other module’s event types, and circular imports start creeping in. Colocation avoids all of that.

The principle generalizes to any project. If your types.ts has 800 lines and 40 imports, the types should probably live next to the code that uses them. If your constants.ts has values that only one module reads, move them into that module. A shared folder should only contain things that are genuinely shared.

Use the type system to prevent mistakes

Every entity ID in OpenCode is a branded string. SessionID, MessageID, PartID, ProviderID, ModelID are all distinct types that TypeScript enforces at compile time. You cannot pass a SessionID where a MessageID is expected.

export const SessionID = Schema.String.pipe(
  Schema.brand("SessionID"),
  withStatics((s) => ({
    make: (id: string) => s.makeUnsafe(id),
    descending: (id?: string) => s.makeUnsafe(Identifier.descending("session", id)),
    zod: Identifier.schema("session").pipe(z.custom<Schema.Schema.Type<typeof s>>()),
  })),
)
export type SessionID = Schema.Schema.Type<typeof SessionID>

The ID generator uses monotonic timestamps with configurable sort direction. Sessions use descending so recent ones sort first. Messages use ascending so they stay in order. Every ID is prefixed (ses_, msg_, prt_) so you can identify what something is by looking at the raw string.

const prefixes = {
  event: "evt",
  session: "ses",
  message: "msg",
  permission: "per",
  question: "que",
  user: "usr",
  part: "prt",
  pty: "pty",
  tool: "tool",
  workspace: "wrk",
} as const

You do not need Effect or branded types from a library to do this. A simple approach works fine:

type SessionID = string & { readonly __brand: "SessionID" }
type MessageID = string & { readonly __brand: "MessageID" }

function createSessionID(id: string): SessionID { return id as SessionID }
function createMessageID(id: string): MessageID { return id as MessageID }

That alone prevents the entire category of “wrong ID passed to wrong function” bugs. The fancier version with Zod validation and static helpers is nicer, but even the two line version catches real mistakes at compile time instead of at 3am in production.

Extension points should be simpler than internals

The plugin type in OpenCode is one line:

export type Plugin = (input: PluginInput, options?: PluginOptions) => Promise<Hooks>

A plugin receives a typed client, project info, directory paths, and a shell. It returns hooks. The hook system uses the same trigger pattern everywhere: input is read only context, output is the mutable thing the plugin can modify. Plugin authors do not need to understand Effect, the service layer, the bus, or the dependency graph. They get typed inputs, they modify outputs, and they are done.

The tool system does the same thing. Tool.define() wraps every tool with parameter validation and output truncation automatically:

export function define<Parameters extends z.ZodType, Result extends Metadata>(
  id: string,
  init: Info<Parameters, Result>["init"] | Def<Parameters, Result>,
): Info<Parameters, Result> {
  return {
    id,
    init: async (initCtx) => {
      const toolInfo = init instanceof Function ? await init(initCtx) : init
      const execute = toolInfo.execute
      toolInfo.execute = async (args, ctx) => {
        try {
          toolInfo.parameters.parse(args)
        } catch (error) {
          // validation error handling
        }
        const result = await execute(args, ctx)
        const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
        return { ...result, output: truncated.content, /* ... */ }
      }
      return toolInfo
    },
  }
}

Individual tools just define a Zod schema and an execute function. Validation, truncation, error formatting are all handled by the wrapper. The registry then dynamically filters tools based on the model (GPT models get apply_patch instead of edit/write) and the agent (explore only gets read only tools). Each tool is one file, self contained, with no knowledge of how the registry assembles them.

The principle applies to any system with extension points: APIs, plugin systems, webhook handlers, middleware. The people extending your system should not need to understand how it works internally. If your plugin API requires importing five internal modules and understanding your DI framework, the API is too complex.

One directory, one concern

The packages/opencode/src/ directory has about 30 subdirectories, and each one owns exactly one domain. The tool/ directory is a good example: tool.ts defines the base type, registry.ts manages the registry, and then there is one file per tool (bash.ts, edit.ts, grep.ts, read.ts, write.ts) with a matching .txt file for the tool’s description.

At the monorepo level, the same discipline holds.

PackagePurpose
opencodeCore business logic, server, TUI
appShared web UI (SolidJS)
desktopTauri desktop wrapper
plugin@opencode-ai/plugin types for plugin authors
sdkGenerated client SDK

The core package contains everything that makes OpenCode work. The desktop app wraps the shared web UI. The SDK is generated from the OpenAPI spec that comes from the Hono routes. The plugin package is just the types that plugin authors need. Dependencies flow one direction with no cycles.

When you need to find something, the file name tells you where it is. When you need to change something, you know exactly which files are involved. There are no 2000 line god files where the session logic lives next to the config parser next to the permission evaluator. Each concern has a home, and that home has a door.

The repo is at github.com/anomalyco/opencode. I would recommend cloning it and reading for an hour. Not for the AI agent stuff, but for the patterns. Most of them work in any TypeScript project, and a few of them (branded IDs, colocation, constraining powerful tools) work in any language.