Practical techniques discovered while building the opencode-aware plugin.
There are two ways to load a local plugin:
Option 1 (recommended): .opencode/plugins/ directory
Place a .js or .ts file in .opencode/plugins/. OpenCode automatically
loads all files in that directory at startup — no config entry needed.
.opencode/
plugins/
my-plugin.js ← auto-loaded
Option 2: npm package names via config
The plugin array in opencode.jsonc is for npm package names only, not
file paths. Use this for published packages:
Do not put absolute file paths like
"/tmp/plugin/index.js"in thepluginarray — OpenCode treats them as npm package names and will fail to load them.
OpenCode loads configs whenever it starts with that directory as the working
directory. The global config at ~/.config/opencode/opencode.jsonc is separate
and should be left untouched for project-specific plugins.
Run any one-shot command with debug logging enabled:
opencode run --print-logs --log-level DEBUG "say hello"If the plugin is registered and the export is valid you will see two lines near the top of the output:
INFO service=plugin path=file:///...your-plugin/src/index.ts loading plugin
INFO service=your-service plugin loaded
The second line is produced by the plugin itself via client.app.log() (see
Logging below). If only the first line appears, the plugin threw
during initialisation.
Use client.app.log() for all output from inside a plugin. console.log is
swallowed; only the structured log API surfaces entries in OpenCode's log
stream.
client.app.log({
body: {
service: "your-service", // appears as the "service=" field
level: "info", // "debug" | "info" | "warn" | "error"
message: "something happened",
extra: { sessionID, count }, // optional key-value bag
},
})Stream logs to stderr in real time with:
opencode run --print-logs --log-level DEBUG "your prompt"Logs are also written to timestamped files (last 10 kept) under:
~/.local/share/opencode/log/
Tail the most-recent file to monitor a running session:
tail -f $(ls -t ~/.local/share/opencode/log/*.log | head -1)opencode run exits as soon as the session completes. This is enough to confirm
the plugin loads and handles synchronous events, but it is too short-lived for
testing anything that requires idle time or repeated event cycles.
To keep a plugin-loaded server running indefinitely, use the ACP server with an
explicit --cwd pointing at your project:
opencode acp --print-logs --log-level DEBUG --port 9997 \
--cwd /path/to/your/projectThe --cwd flag is what causes OpenCode to read the project's
.opencode/opencode.jsonc and load your plugin. Without it (e.g. with
opencode serve), only the global config is read and project plugins are
skipped.
Once a server is running you can drive it from the shell using the HTTP API.
curl http://127.0.0.1:9997/sessionThis is the same call your plugin makes via client.session.promptAsync().
The REST path is /session/{id}/prompt_async (note the underscore).
curl -X POST http://127.0.0.1:9997/session/<SESSION_ID>/prompt_async \
-H "Content-Type: application/json" \
-d '{"parts": [{"type": "text", "text": "Say CONFIRMED"}]}'A 204 No Content response means the prompt was accepted and queued. The AI
response will appear asynchronously in the session.
curl http://127.0.0.1:9997/session/<SESSION_ID>/messageThe session.idle event fires when a session transitions from active to idle
(i.e. after the last model response completes and no new user message has
arrived). It is published on the internal event bus, so the plugin's event
hook receives it.
To observe it in logs, grep for the bus publish line alongside your handler's output:
INFO service=bus type=session.idle publishing
DEBUG service=your-service sessionID=... idle detected
Important: in opencode run the process exits at the same timestamp that
session.idle fires for the first time, because the session ends immediately
after the response. Two-phase idle detection (waiting for a second event after
a threshold) only works in long-lived sessions (the interactive TUI or
opencode acp).
If your plugin acts after N minutes of idle time, hardcoding 5 minutes into
tests is impractical. The cleanest pattern is a single constant in types.ts
that you can change temporarily:
// types.ts
export const IDLE_THRESHOLD = 10 * 1000 // 10 s — TEMP: restore to 5 * 60 * 1000Change, observe, restore, confirm tests still pass. The threshold is only referenced in one place so the diff is trivial and mechanical.
Relevant types (from @opencode-ai/sdk):
| Symbol | Notes |
|---|---|
Event |
Union of all bus event types; import from @opencode-ai/sdk |
event.properties.sessionID |
Correct field name on session.idle events |
client.session.promptAsync({ path: { id }, body: { parts } }) |
Fire-and-forget; returns void |
client.app.log({ body: { service, level, message, extra? } }) |
Structured logging |
The event object does not have top-level sessionId or idleTime fields.
All session-specific data lives under event.properties.
"moduleResolution": "bundler"intsconfig.json— required for Bun + ESM- Import source files with
.jsextensions even though the files are.ts - Add
"bun-types"to"types"intsconfig.jsonforbun:test - Export only functions — OpenCode iterates
Object.values(module)and calls each export as a plugin function. Any non-function export (e.g. a tool object) causesPlugin export is not a functionand aborts loading. Keep only the plugin function(s) as named exports; use unexported local variables for everything else. export defaultis optional — OpenCode recognises both named and default exports, but the rule above still applies to all exports present.- No
require(), no CommonJS
Local plugins in .opencode/plugins/ cannot resolve npm imports without a
package.json alongside them. Two approaches work:
1. Bundle all dependencies into a single file (used by this project):
bun build src/index.ts --outfile .opencode/plugins/my-plugin.js \
--target bun --format esm2. Add a package.json to .opencode/ and let OpenCode run bun install:
// .opencode/package.json
{ "dependencies": { "@opencode-ai/plugin": "^1.3.0" } }With option 2 the plain tsc output works because dependencies are installed
into .opencode/node_modules/.