| type | Developer Reference | |||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| title | OpenCode Plugin Architecture | |||||||||||||||||||||
| description | The required construction and execution structure for local OpenCode plugins in KyzoCode. | |||||||||||||||||||||
| status | draft | |||||||||||||||||||||
| generated |
|
|||||||||||||||||||||
| sources |
|
This is how local OpenCode plugins are built in KyzoCode. Zod schemas are the domain's executable vocabulary. TypeScript executes and projects those schemas; it does not maintain a second, handwritten type model.
The goal is structural control: raw values enter through schema construction, domain outcomes form a closed algebra, and effects remain at the OpenCode boundary.
Each plugin begins with three files:
<plugin>/
├── type.ts
├── value.ts
└── index.ts
The dependency graph is acyclic:
type.ts <- value.ts <- index.ts
type.ts imports only Zod. It defines semantic atoms: branded primitives and
literal value spaces. It contains no OpenCode imports, effects, interfaces, or
composite domain facts.
value.ts imports Zod and type.ts. It composes atoms into strict objects and
closed discriminated unions. It contains no OpenCode imports or effects.
index.ts imports value.ts and @opencode-ai/plugin. It is the sole SDK and
effect boundary. It binds schemas to OpenCode, executes effects, normalizes
external failures into declared domain outcomes, and projects those outcomes
to the SDK's result shape.
OpenCode discovers direct files under .opencode/plugins/. A plugin whose
three-file implementation lives outside that loader directory uses a trivial
loader file that only re-exports its index.ts; the loader carries no second
domain model or behavior. Project Knowledge uses this
shape so helper modules are not independently loaded as plugins.
Domain types are never authored independently of their schemas:
export const Candidate = z.string().min(1).brand<"Candidate">()The schema owns the meaning and construction proof. Do not add a handwritten
interface or type alias beside it, including an alias whose only purpose is to
name z.infer. Let consumers infer through the schema-bearing API so there is
no second domain structure to maintain.
Composite facts use strict objects. Alternatives use literal discriminators and discriminated unions:
export const EvaluatedFact = z.strictObject({
kind: z.literal("evaluated"),
candidate: Candidate,
})
export const EngineFault = z.strictObject({
kind: z.literal("engine-fault"),
message: z.string().min(1),
})
export const Verdict = z.discriminatedUnion("kind", [
EvaluatedFact,
EngineFault,
])The union is the complete outcome space. Failures from external execution are
caught only at index.ts and constructed as EngineFault; exceptions are not
used as domain control flow.
The pinned plugin SDK1 accepts a Zod raw shape as a tool's
args, constructs the parsed argument object before execute, and supplies
the execution context.2
tool({
description: "Evaluate a candidate",
args: EvaluationRequest.shape,
async execute(request, context) {
const verdict = await evaluate(request, context)
return {
title: "Evaluation",
output: JSON.stringify(verdict),
}
},
})Network calls, shell execution, file access, and model inference occur at this
boundary. context.abort, context.directory, and context.worktree carry
the execution state supplied by OpenCode.2
OpenCode tools return a string or the SDK's ToolResult object, not an
arbitrary domain object. The shell therefore serializes the already-constructed
domain outcome; serialization does not redefine it.2
Plugin-wide cleanup is returned through the SDK's dispose hook. The current
tool context does not expose a shutdown event.3
Local plugin code executes with the authority of the OpenCode host process; tool permission rules do not sandbox plugin initialization or hooks. Only load project plugins from a trusted checkout, and run untrusted repositories inside the isolation required by the Security Baseline.
Do not introduce generic domain wrappers merely to share implementation. If a tool accepts a schema as data, represent that schema with a declared immutable schema value and construct it before use. The plugin translates one concrete request fact into one concrete outcome union.
- Name and construct the semantic atoms in
type.ts. - Compose request facts and the complete outcome union in
value.ts. - Bind the request object's shape to the SDK tool in
index.ts. - Keep every effect and external exception at
index.ts. - Construct every success and failure as a member of the declared outcome.
- Serialize the constructed outcome to
ToolResultwithout redefining it. - Gate the import graph, schema construction, and SDK contract.