These notes describe how to work on packages/core during the v2 port.
Move behavior out of large application services and into plugins. Core services should become small, typed containers that own state, expose simple operations, and trigger hooks where policy or integration-specific logic belongs.
The target shape is:
packages/core contains domain schemas, typed errors, state containers, events, and plugin hook contracts.packages/opencode becomes thinner over time: UI, server routes, CLI, storage glue, and legacy compatibility should call the core services instead of owning domain logic directly.Core services should look like Catalog, AccountV2, and AgentV2:
Schema.TaggedErrorClass errors for expected failuresInterface with small operationsContext.Servicelayer with private in-memory statedefaultLayer with explicit dependenciesexport * as Name from "./file"Prefer a dumb container API:
get, all, available, default, update, remove, activate, or other small domain verbsupdate(id, draft => ...) for registration and mutationAvoid putting application policy directly in core services unless it is a domain invariant. For example, resolving model endpoint inheritance is catalog-owned; deciding which providers to register is plugin-owned.
Plugins are the extension boundary for v2. Add hooks to PluginV2.HookSpec when logic should be provided by integrations instead of the container itself.
Hook conventions:
cancel: boolean when plugins can prevent a mutationprovider.update, model.update, account.activate, agent.generateUse hooks for:
Do not use hooks as a dumping ground for transport concerns, UI behavior, or compatibility shims.
Built-in core plugins are registered by packages/core/src/plugin/boot.ts.
When a new core service is intended to be available to plugins:
addPluginBoot.defaultLayer only when that does not create a cycleKeep boot as composition only. It should not contain provider, account, agent, or model policy itself.
Core should not import from packages/opencode. If a type or concept is needed by core, move or remodel the domain shape in core first.
Avoid moving legacy services over wholesale. Port the domain shape and the container API, then leave specific behavior behind hooks for plugins to implement.
When porting an opencode service:
packages/coreUse Effect schemas as the public contract:
Schema.Class or Schema.Struct for domain dataSchema.TaggedErrorClass for expected errorsDeepMutable, statics, and integer schemas where appropriatePrefer Info objects as the stored domain records. Add static empty(...) constructors when update APIs need to create records on first mutation.
Keep schemas stable and explicit. Do not rely on opencode config shapes as core domain shapes unless the config shape is actually the domain model.
Keep state private to the service layer. Use immutable replacement or Effect refs when persistence/concurrency requires it.
Publish events for committed domain changes, not for attempted mutations. Event names should describe domain facts, for example catalog.model.updated.
The v2 goal is granular reconfiguration. A model update should let dependents react to that model update; it should not require global reloads.
Follow the local core style:
Effect.gen(function* () { ... }) for compositionEffect.fn("Domain.method") for public service methodsEffect.fnUntraced for small internal mutation helpersyield* new ErrorClass(...) for typed failuresany unless an existing plugin boundary requires itPrefer the smallest correct port. The goal is to make services easier to replace and reason about, not to recreate the old architecture in a new package.