| name | opencode-plugin |
| description | CRITICAL: Load when building, modifying, updating or debugging OpenCode plugins. Missing this = silent failures, broken hooks, and wasted hours. Covers server vs TUI plugin types, event hooks, state API, SolidJS UI slots, config loading, and deployment. Works for ANY plugin type. |
When to use me
- Building a new OpenCode plugin (server or TUI)
- Debugging an existing plugin that doesn't load or behave correctly
- Understanding the OpenCode plugin API (
api.state, api.client, api.event, slots)
- Working with OpenTUI SolidJS components in a TUI plugin
- Configuring plugin registration in
tui.json or opencode.json
Not intended for
- General SolidJS development outside OpenCode
- OpenCode SDK client usage (standalone scripts) → use SDK docs directly
- OpenCode configuration (providers, models, themes) → use
/config
Step 0 — Understand the two plugin systems
OpenCode has two separate, mutually exclusive plugin systems:
| Server Plugin | TUI Plugin |
|---|
| Purpose | Hook into events, shell env, tools | UI in sidebar, commands, routes |
| File ext | .js or .ts | .tsx (JSX required) |
| Export | export const MyPlugin = async ({ $, client, ... }) => { return hooks } | export default { id, tui } |
| Auto-loaded | ✅ from .opencode/plugins/ or ~/.config/opencode/plugins/ | ❌ must register in tui.json |
| Config file | opencode.json (optional, npm plugins) | tui.json (required) |
| Types | Plugin from @opencode-ai/plugin | TuiPlugin, TuiPluginModule from @opencode-ai/plugin/tui |
A single module can only be one type. TuiPluginModule has server?: never.
Step 1 — Server plugin structure
export const MyPlugin = async ({ project, client, $, directory, worktree }) => {
return {
event: async ({ event }) => {
if (event.type === "session.idle") {
}
},
"tool.execute.before": async (input, output) => {
},
"tool.execute.after": async (input, output) => {
},
"shell.env": async (input, output) => {
output.env.MY_VAR = "value"
},
tool: {
mytool: tool({
description: "Does something",
args: { foo: tool.schema.string() },
async execute(args, context) {
return `Result: ${args.foo}`
},
}),
},
"experimental.session.compacting": async (input, output) => {
output.context.push("## Custom context...")
},
}
}
Available server events
session.created, session.updated, session.deleted, session.idle,
session.error, session.compacted, session.status, session.diff
permission.asked, permission.replied
message.updated, message.removed, message.part.updated, message.part.removed
tool.execute.before, tool.execute.after
file.edited, file.watcher.updated
shell.env, command.executed
server.connected, installation.updated
lsp.client.diagnostics, lsp.updated
todo.updated
Server plugin context fields
| Field | Description |
|---|
project | Current project information |
directory | Current working directory |
worktree | Git worktree path |
client | OpenCode SDK client for API calls |
$ | Bun's shell API for executing commands |
Config file loading (server plugins)
Use new URL("./config.json", import.meta.url).pathname to resolve paths relative to the plugin file. Use Bun.file() for reading.
const configPath = new URL("./my-plugin.config.json", import.meta.url).pathname
const configFile = Bun.file(configPath)
const config = (await configFile.exists())
? JSON.parse(await configFile.text())
: defaultConfig
Step 2 — TUI plugin structure
import type { TuiPlugin, TuiPluginModule, TuiPluginApi } from "@opencode-ai/plugin/tui"
import { createSignal, createEffect, createMemo, onCleanup } from "solid-js"
function MyComponent(props: { api: TuiPluginApi; session_id: string }) {
return <box flexDirection="column" gap={0}>
<text fg="#ffffff">Hello from plugin!</text>
</box>
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 175,
slots: {
sidebar_content(_ctx, props) {
return <MyComponent api={api} session_id={props.session_id} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = {
id: "my-tui-plugin",
tui,
}
export default plugin
TUI plugin registration in tui.json
Critical: TUI plugins MUST be registered in tui.json. They are NOT auto-loaded.
Location: ~/.config/opencode/tui.json (global) or .opencode/tui.json (project).
Path resolution: Relative paths resolve relative to the tui.json file location. Use ./ prefix:
{
"$schema": "https://opencode.ai/tui.json",
"plugin": ["./plugins/my-tui-plugin.tsx"]
}
The opencode repo itself uses this pattern: ./plugins/tui-smoke.tsx in .opencode/tui.json.
Sidebar slots
| Slot | Mode | Props | Notes |
|---|
sidebar_title | single_winner | { session_id, title, share_url? } | Replaces title area |
sidebar_content | additive (default) | { session_id } | Main content, ordered by order |
sidebar_footer | single_winner | { session_id } | Replaces version info |
Built-in order values:
| Plugin | Order |
|---|
internal:sidebar-context | 100 |
internal:sidebar-mcp | 200 |
internal:sidebar-lsp | 300 |
internal:sidebar-todo | 400 |
internal:sidebar-files | 500 |
Use 175 to insert between context and MCP. Use > 500 to appear below all built-ins.
Step 3 — TuiPluginApi reference
| Field | Description |
|---|
api.state | Reactive SolidJS-backed state (sessions, messages, parts, providers, config) |
api.state.config | SdkConfig — has model?: string in "provider/model" format |
api.state.provider | ReadonlyArray<Provider> — each has { id, name, source, env, models } |
api.state.session.messages(sessionID) | ReadonlyArray<Message> — capped at 100 |
api.state.part(messageID) | ReadonlyArray<Part> for a message |
api.client | Full SDK client — use for data beyond 100-msg cap |
api.event.on(type, handler) | Subscribe to events; returns unsubscribe fn |
api.kv | Persistent key-value store (get/set/ready) |
api.lifecycle.onDispose(fn) | Plugin-level cleanup; returns unregister fn |
api.slots.register(plugin) | Register UI slots; returns slot id |
api.ui | toast(), dialog, Dialog, DialogSelect, Slot, Prompt |
api.theme | Current theme colors (foreground, muted, etc.) |
api.command.register(cb) | Register slash commands; returns unregister fn |
api.route | Register routes, navigate |
Reading the active model/provider
const model = api.state.config?.model
const providers = api.state.provider
const messages = api.state.session.messages(sessionID)
const userMsg = messages.find(m => m.role === "user")
if (userMsg) {
const model = (userMsg as any).model as { providerID: string; modelID: string; variant?: string }
}
api.event.on("message.updated", (event) => {
const msg = event.properties.info
if (msg.role !== "user") return
const userModel = (msg as any).model as { providerID: string; modelID: string } | undefined
})
const lastAssistant = [...messages].reverse().find(m => m.role === "assistant")
if (lastAssistant) {
}
Fetching messages beyond the 100 cap
const result = await api.client.session.messages({
sessionID: props.session_id,
limit: 10000,
})
Step 4 — SolidJS patterns for TUI plugins
Component lifecycle
function MyComponent(props: { api: TuiPluginApi; session_id: string }) {
const [data, setData] = createSignal(null)
createEffect(() => {
const sessionID = props.session_id
fetchData(sessionID).then(setData)
const unsub = props.api.event.on("session.compacted", handler)
onCleanup(unsub)
})
return <text>{data() ?? "Loading..."}</text>
}
Critical: NEVER use async/await inside createEffect
Reactive tracking stops at the first await. Capture values synchronously, then use .then():
createEffect(async () => {
const id = props.session_id
const data = await fetchData(id)
setData(data)
})
createEffect(() => {
const id = props.session_id
fetchData(id).then(setData)
})
Component-level cleanup (NOT api.lifecycle)
Inside SolidJS components, use onCleanup — NOT api.lifecycle.onDispose:
createEffect(() => {
const unsub = props.api.event.on("message.updated", handler)
onCleanup(unsub)
})
api.lifecycle.onDispose is for plugin-level cleanup only (in the tui function scope).
Polling fallback for first-load race condition
If api.state isn't populated on first render, use a polling interval as fallback:
const modelPoller = setInterval(() => {
const detected = detectModel(props.api)
if (detected !== currentModel()) {
setCurrentModel(detected)
}
}, 2000)
onCleanup(() => clearInterval(modelPoller))
Never return null from the root component
Returning null causes the component to unmount. Use conditional rendering inside a persistent container:
if (!isActive()) return null
return <box>...</box>
return (
<box flexDirection="column" gap={0}>
{isActive() ? <box>...</box> : null}
</box>
)
Step 5 — OpenTUI components
Layout
<box> — Container with flexDirection, gap, border, padding, backgroundColor
<scrollbox> — Scrollable container
<text> — Styled text with fg, bg, content props
Text modifiers (inside <text>)
<strong>, <b> — Bold
<em>, <i> — Italic
<span> — Inline styled text
<br> — Line break
Colors
Use hex strings: "#ffffff", "#22c55e", "#eab308", "#ef4444", "#888888"
Access theme colors via api.theme.current:
<text fg={api.theme.current?.foreground ?? "#ffffff"}>Text</text>
<text fg={api.theme.current?.muted ?? "#888888"}>Dim text</text>
Step 6 — Debugging
Logging (TUI plugin)
Use fs.appendFileSync — NOT Bun.write (append mode is broken):
import { mkdirSync, appendFileSync } from "node:fs"
const DEBUG = process.env.MY_PLUGIN_DEBUG !== "false"
const logPath = new URL("./logs/my-plugin.log", import.meta.url).pathname
if (DEBUG) mkdirSync(new URL("./logs", import.meta.url).pathname, { recursive: true })
const log = (...args: unknown[]) => {
if (!DEBUG) return
const line = `[${new Date().toISOString()}] ${args.map(String).join(" ")}\n`
appendFileSync(logPath, line)
}
Logging (server plugin)
await client.app.log({
body: { service: "my-plugin", level: "info", message: "Something happened" },
})
Common pitfalls
Bun.write with { append: true } overwrites — use fs.appendFileSync
api.state.provider is a ReadonlyArray, not a single object — use (api.state.provider ?? []) for null safety
api.state.session.messages(sessionID) requires sessionID as argument
UserMessage.model is { providerID, modelID } (object, not a string) and is REQUIRED — it is always present on every user message (including compaction messages)
AssistantMessage has modelID and providerID as separate top-level fields (not nested under model)
- TUI plugins are NOT auto-loaded — must register in
tui.json
- Paths in
tui.json resolve relative to the config file — use ./ prefix
EventMessageUpdated has { type, properties: { sessionID, info: Message } }
- TUI plugins fail silently if not registered — check
tui.json if plugin doesn't appear
- Never store
0 in a deduplication map to mark a message as "counted but skipped" — this permanently blacklists the ID and prevents any future retry (e.g. if the model becomes available on a later message.updated fire)
- Always read
UserMessage.model directly — do NOT scan adjacent AssistantMessages to infer the model; for the first message in a session no assistant has responded yet, making the scan return null and silently skip counting
session.compacted is a BusEvent (ephemeral) — it fires in real-time but is NOT replayed when loading historical sessions. To detect past compactions, scan messages for parts with type: "compaction"
UserMessage.summary is present on ALL user messages (it is part of the UserMessage schema) — it is NOT specific to compaction messages. Never use it as a compaction indicator.
parts.some(p => p.synthetic) is wrong for synthetic detection — the first real user message in a session can have mixed parts (e.g. text, file, text[synthetic]). Using .some() incorrectly marks it as synthetic and skips counting it. The only reliable check is: a message is synthetic if and only if it has at least one {type:"text", synthetic:true} part AND has NO non-synthetic parts (i.e. isSyntheticMessage() with the "all parts must be synthetic" condition).
Step 7 — Deployment
File layout
~/.config/opencode/
├── tui.json ← TUI plugin registration
├── plugins/
│ ├── my-tui-plugin.tsx ← TUI plugin (SolidJS)
│ ├── my-tui-plugin.config.json
│ ├── my-server-plugin.js ← Server plugin (hooks)
│ ├── my-server-plugin.config.jsonc
│ └── logs/ ← .gitignore'd debug logs
Sync pattern (dotfiles repo)
When maintaining plugins in a dotfiles repo, copy files to ~/.config/opencode/:
cp .config/opencode/plugins/*.tsx ~/.config/opencode/plugins/
cp .config/opencode/plugins/*.js ~/.config/opencode/plugins/
cp .config/opencode/plugins/*.json* ~/.config/opencode/plugins/
cp .config/opencode/plugins/tui.json ~/.config/opencode/tui.json
.gitignore
Add to root .gitignore:
.config/opencode/plugins/logs/
Step 8 — Event types reference
SyncEvents are persisted to the DB and replayed to clients when they connect or load a session.
BusEvents are ephemeral — they fire in real-time only and are NOT replayed for historical sessions.
Session events (SyncEvents unless noted)
EventSessionCreated
EventSessionUpdated
EventSessionDeleted
EventSessionIdle
EventSessionError
EventSessionCompacted
EventSessionStatus
EventSessionDiff
Session has parentID?: string — sessions with parentID are subagent sessions.
Message events (SyncEvents — all replayed on load)
EventMessageUpdated
EventMessageRemoved
EventMessagePartUpdated
EventMessagePartRemoved
EventMessageUpdated fires for BOTH user and assistant message updates (including streaming updates as the assistant responds). Filter by msg.role.
UserMessage shape (from OpenCode schema — all fields)
{
id: string
sessionID: string
role: "user"
model: {
providerID: string
modelID: string
variant?: string
}
agent: string
time: { created: number }
format?: OutputFormat
summary?: { title?: string; body?: string; diffs: FileDiff[] }
system?: string
tools?: Record<string, boolean>
}
AssistantMessage shape (from OpenCode schema — key fields)
{
id: string
sessionID: string
role: "assistant"
parentID: string
modelID: string
providerID: string
mode: string
agent: string
summary?: boolean
cost: number
tokens: {
input: number; output: number; reasoning: number
cache: { read: number; write: number }
total?: number
}
time: { created: number; completed?: number }
}
Part types (type discriminator)
type | Key fields | Notes |
|---|
"text" | text: string, synthetic?: boolean, ignored?: boolean | synthetic: true = injected, not a real user input |
"reasoning" | text: string | Model reasoning trace |
"tool" | callID, tool, state | Tool call; state has status: pending|running|completed|error |
"compaction" | auto: boolean, overflow?: boolean | Marks a compaction user message |
"step-finish" | reason, cost, tokens | End of an LLM response step |
"step-start" | snapshot? | Start of an LLM response step |
"file" | mime, url, filename?, source? | Attached file |
"patch" | hash, files: string[] | Git patch |
"snapshot" | snapshot: string | Filesystem snapshot |
"subtask" | prompt, description, agent, model? | Subagent task |
"agent" | name, source? | Agent invocation |
"retry" | attempt, error: APIError | Retry event |
All parts share: { id: PartID, sessionID: SessionID, messageID: MessageID }.
Permission events
EventPermissionAsked
EventPermissionReplied
Tool events
EventToolExecuteBefore
EventToolExecuteAfter
Step 9 — Session compaction internals
When a session is compacted, OpenCode performs these steps (source: packages/opencode/src/session/compaction.ts):
What happens during compaction
create() — Creates a user message with a compaction part type and model set to the current model
process() — Makes a real LLM API call to generate a summary (consumes a premium request)
process() — Creates an assistant message with summary: true, cost: 0, and modelID/providerID set
- If auto mode and
result === "continue":
- Creates a synthetic "continue" user message with
model set (copied from triggering user message) and a text part with synthetic: true
- Publishes
session.compacted event (BusEvent — ephemeral, not replayed on load)
Message types created
| Message | Role | Part type | model field? | Consumes premium request? |
|---|
| Compaction user message | user | compaction | ✅ Yes (required) | Yes (via LLM call) |
| Assistant summary | assistant | text (summary) | N/A (modelID/providerID set) | No (cost: 0) |
| Synthetic "continue" | user | text (synthetic: true) | ✅ Yes (copied from triggering msg) | No |
Detecting message types
In message.updated handler — use api.state.part(messageID):
const parts = api.state.part(msg.id!) ?? []
const isCompaction = parts.some((p) => p.type === "compaction")
const isSynthetic = isSyntheticMessage(parts)
Where isSyntheticMessage must be defined as:
function isSyntheticMessage(parts: Part[]): boolean {
if (parts.some((p) => p.type === "compaction")) return false
const syntheticTextParts = parts.filter((p) => p.type === "text" && (p as any).synthetic === true)
if (syntheticTextParts.length === 0) return false
const nonSyntheticParts = parts.filter((p) => !(p.type === "text" && (p as any).synthetic === true))
return nonSyntheticParts.length === 0
}
In fetchSessionUsage (load path) — use item.parts from api.client.session.messages():
const parts = (item as any).parts as Part[] ?? []
const isSynthetic = parts.some(p => p.type === "text" && (p as any).synthetic === true)
Note: api.state.part() works for historically loaded sessions — parts are hydrated from the DB via the SyncEvent replay mechanism.
Recommended handling for usage-counting plugins
const userModel = (msg as any).model as { providerID: string; modelID: string } | undefined
const model = userModel?.providerID && userModel?.modelID
? `${userModel.providerID}/${userModel.modelID}` : null
const parts = api.state.part(msg.id!) ?? []
if (isSyntheticMessage(parts)) {
return
}
if (model && isCopilotModel(model)) {
const multiplier = getMultiplier(model, config)
if (!messageMultipliers.has(msg.id)) {
messageMultipliers.set(msg.id, multiplier)
setUsage(prev => prev + multiplier)
}
}
api.event.on("session.compacted", () => {
fetchQuota()
})
Why NOT to scan adjacent AssistantMessages for the model: For the first message in a session, no assistant has responded yet — the scan returns null and the message is silently skipped. UserMessage.model is a required schema field and is always populated at message creation time.
Step 10 — Counting session usage (premium requests)
Key design principles
- Read
UserMessage.model directly — it is always set, even for compaction messages
- Only store in the deduplication map when counting — do NOT store
0 for skipped messages; that permanently blacklists the ID
- Use
api.client.session.messages() for the load path — it returns { info, parts }[] for all messages; api.state.session.messages() is capped at 100
session.compacted is ephemeral — for historical sessions, detect compaction by scanning for parts with type: "compaction"
Load path — counting from session history
async function countSessionUsage(sessionID: string, api: TuiPluginApi, config: MyConfig) {
const result = await api.client.session.messages({ sessionID, limit: 10000 })
const messages = result.data ?? []
let total = 0
const counted = new Map<string, number>()
for (const item of messages) {
if (item.info?.role !== "user") continue
const parts = ((item as any).parts ?? []) as Part[]
if (isSyntheticMessage(parts)) continue
const infoModel = (item.info as any)?.model
const model = infoModel?.providerID && infoModel?.modelID
? `${infoModel.providerID}/${infoModel.modelID}` : null
if (!model || !model.toLowerCase().includes("copilot")) continue
const multiplier = getMultiplier(model, config)
const msgId = item.id ?? (item.info as any)?.id
if (msgId) {
counted.set(msgId, multiplier)
total += multiplier
}
}
return { total, counted }
}
Live path — counting from message.updated events
const messageMultipliers = new Map<string, number>()
api.event.on("message.updated", (event) => {
const msg = event.properties.info
if (msg.role !== "user") return
if (msg.id && messageMultipliers.has(msg.id)) return
const parts = (api.state.part(msg.id!) ?? []) as Part[]
if (isSyntheticMessage(parts)) return
const userModel = (msg as any)?.model as { providerID: string; modelID: string } | undefined
let model = userModel?.providerID && userModel?.modelID
? `${userModel.providerID}/${userModel.modelID}` : null
if (!model) {
const msgs = api.state.session.messages(sessionID)
for (let i = msgs.length - 1; i >= 0; i--) {
if (msgs[i].role === "assistant" && msgs[i].providerID && msgs[i].modelID) {
model = `${msgs[i].providerID}/${msgs[i].modelID}`
break
}
}
}
if (!model) model = api.state.config?.model ?? null
if (!model || !model.toLowerCase().includes("copilot")) return
const multiplier = getMultiplier(model, config)
messageMultipliers.set(msg.id!, multiplier)
setUsage(prev => prev + multiplier)
})
Compaction messages — are they counted automatically?
Yes. The compaction user message has UserMessage.model set just like any other user message. It will be picked up by both the load path and the live path above without any special handling. Its parts have type: "compaction" (not type: "text" && synthetic: true), so it passes the synthetic filter and gets counted as 1 premium request.
References