This repository contains officially supported OpenCode plugins for the KubeOpenCode project.
KubeOpenCode is a Kubernetes-native AI Agent Platform that wraps OpenCode with enterprise infrastructure — governance, RBAC, persistence, scheduling, and multi-tenant agent management. Agents run as Kubernetes Deployments with OpenCode as the coding engine inside each Pod.
This repo (kubeopencode-plugins) houses first-party plugins that extend Agent capabilities. Each plugin is an independent npm package that follows OpenCode's plugin API and can be installed into any Agent via spec.plugins.
IMPORTANT: The OpenCode project source is at
../opencode/and the KubeOpenCode project source is at../kubeopencode/. Always search local codebases before using web search.
- OpenCode plugin API:
../opencode/packages/plugin/src/index.ts— definesPlugin,PluginModule,Hooks,PluginInput - OpenCode plugin loader:
../opencode/packages/opencode/src/plugin/index.ts— how plugins are loaded and hooks are wired - KubeOpenCode plugin install flow:
../kubeopencode/cmd/kubeopencode/plugin_init.go— theplugin-initinit container that runsnpm install - KubeOpenCode Agent plugin spec:
../kubeopencode/api/v1alpha1/agent_types.go(lines 139-183) — CRD fields for declaring plugins - Existing Slack plugin reference:
../kubeopencode/plugins/slack/dist/— the original Slack plugin shipped with KubeOpenCode
-
Plugins are declared in the Agent spec:
spec: plugins: - name: "opencode-slack-plugin" target: server
-
The controller creates a plugin-init init container that runs
npm install --productioninto a shared/pluginsvolume. -
The executor container loads plugins via OpenCode's config
pluginarray usingfile:///plugins/node_modules/<package>paths. -
The executor container does not need npm — it reads pre-installed packages.
server(default): Runs insideopencode serve. Has access toPluginInput.client(full SDK: sessions, prompts, events). This is the target for all plugins in this repo.tui: Runs during interactive terminal sessions. Provides UI extensions.
Every server plugin receives a PluginInput with:
| Field | Type | Description |
|---|---|---|
client |
OpencodeClient |
Full SDK client (session.create, session.prompt, event.subscribe, etc.). Calls bypass HTTP — direct function invocation inside the process. |
project |
Project |
Current project info |
directory |
string |
Working directory |
worktree |
string |
Git worktree root |
serverUrl |
URL |
Server URL |
$ |
BunShell |
Bun shell API |
Plugins return a Hooks object. Key hooks used by plugins in this repo:
| Hook | Description |
|---|---|
event |
Receives ALL bus events (session.idle, message.part.updated, permission.asked, server.instance.disposed, etc.) |
tool |
Register custom tools |
chat.context |
Inject context into LLM prompts |
chat.message |
Intercept new messages |
permission.ask |
Intercept permission requests |
Use the PluginModule format with an id field:
import type { PluginModule } from "@opencode-ai/plugin"
const plugin: PluginModule = {
id: "my-plugin",
server: async (input) => {
// initialization
return {
event: async ({ event }) => { /* ... */ },
}
},
}
export default plugin- Plugins receive credentials via
process.env, injected from Agentspec.credentials(Kubernetes Secrets) - If required env vars are missing, log a warning and return empty hooks
{} - Never hard-fail — a misconfigured plugin should not crash the Agent
Use console.log / console.warn / console.error with a consistent prefix:
console.log("[my-plugin] Connected successfully")
console.warn("[my-plugin] Heartbeat disabled: missing env vars")KubeOpenCode Agents support standby mode — auto-suspend after idle, auto-resume on new task. Plugins that maintain persistent connections (WebSocket, long-polling) must send heartbeat annotations to prevent unexpected auto-suspend:
- Annotation:
kubeopencode.io/last-connection-active - Interval: 60 seconds (match
ConnectionHeartbeatIntervalin controller) - Stop heartbeat after 5 minutes of inactivity to allow idle timer
- Read ServiceAccount token from
/var/run/secrets/kubernetes.io/serviceaccount/token - If AGENT_NAME/AGENT_NAMESPACE/K8s API are unavailable, silently disable heartbeat
See opencode-slack-plugin/src/index.ts for the reference implementation.
Listen for the server.instance.disposed event to clean up connections:
if (evt.type === "server.instance.disposed") {
heartbeat.stop()
connection.disconnect()
}Plugins may run in multiple Agent pods simultaneously. Design for this:
- All state is per-process (in-memory Maps, closures) — no shared files or databases
- Session maps are keyed by unique identifiers (e.g., Slack channel + thread timestamp)
- Bounded collections with eviction to prevent memory leaks
OpenCode writes structured logs (session lifecycle, LLM calls, tool execution, errors) to:
~/.local/share/opencode/log/ # macOS/Linux default ($XDG_DATA_HOME/opencode/log/)
Files are named YYYY-MM-DDTHHMMSS.log (one per startup, 10 most recent retained). Run opencode debug paths to confirm the path on your system.
Plugin console.log/console.warn/console.error output goes to the OpenCode process stdout/stderr — it is not captured in the structured log file. Use the [plugin-name] prefix convention to make plugin output easy to filter.
-
Find the session — search the log for the session ID or creation event:
grep "ses_XXXXX" ~/.local/share/opencode/log/*.log
-
Check for errors — LLM failures, processor crashes, permission timeouts:
grep "ERROR.*ses_XXXXX" ~/.local/share/opencode/log/*.log
-
Key log markers:
service=session ... created— session createdservice=session.prompt step=N loop— prompt loop iteration Nservice=llm ... stream— LLM call startedservice=llm ... stream error— LLM call failed (root cause inerror=JSON)service=session.processor ... error=— processor crashservice=session.prompt ... exiting loop— prompt completed normally
opencode serve --print-logs # logs to stderr instead of file
opencode serve --log-level DEBUG # maximum verbosityBoth can also be set in opencode.json:
{
"logLevel": "DEBUG"
}kubeopencode-plugins/
README.md # Project overview and quick start
AGENTS.md # This file (AI agent dev guidelines)
Makefile # Build and release targets
.github/workflows/publish.yaml # CI: automated npm publish on tag
opencode-slack-plugin/ # Slack Socket Mode integration
src/index.ts # Plugin source
package.json # npm package config (@kubeopencode scope)
tsconfig.json
dist/ # Built output (tsup)
README.md # Setup instructions
Use the Makefile at the repo root:
make install # npm install
make typecheck # tsc --noEmit
make build # tsup -> dist/
make clean # remove dist/To target a specific plugin (when multiple plugins exist):
make build PLUGIN_DIR=opencode-slack-pluginCopy the built plugin to your OpenCode plugins directory:
cp dist/index.js ~/.config/opencode/plugins/slack-plugin.jsOr add to opencode.json:
{
"plugin": ["file:///path/to/opencode-slack-plugin"]
}Create an Agent with the plugin:
apiVersion: kubeopencode.io/v1alpha1
kind: Agent
metadata:
name: my-agent
spec:
plugins:
- name: "@kubeopencode/opencode-slack-plugin"
credentials:
- secretRef:
name: slack-credentials
# ...Releases are automated via GitHub Actions using npm OIDC Trusted Publishing. No npm tokens or secrets are required — authentication uses short-lived OIDC credentials tied to the specific workflow.
-
Bump the version in the plugin's
package.json:cd opencode-slack-plugin npm version patch # or minor / major
-
Commit and push the version bump:
git add -A git commit -s -m "release: opencode-slack-plugin v0.1.1" git push origin main -
Tag and push to trigger CI:
make release # reads version from package.json, creates and pushes tagThe tag format is
<plugin-dir>/v<version>(e.g.opencode-slack-plugin/v0.1.0). This supports independent versioning per plugin.
The .github/workflows/publish.yaml workflow:
- Extracts the plugin directory and version from the git tag
- Runs
npm ci→typecheck→build - Verifies
package.jsonversion matches the tag version - Publishes to npm with OIDC authentication and provenance attestation
OIDC Trusted Publishing requires the package to already exist on npm:
- Publish v0.1.0 manually:
npm login && make publish PLUGIN_DIR=my-new-plugin - On npmjs.com, go to the package settings and add a Trusted Publisher:
- Owner:
kubeopencode, Repository:kubeopencode-plugins - Workflow:
publish.yaml, Environment:release
- Owner:
- Ensure the
releaseenvironment exists in the GitHub repo settings
All plugins are published under the @kubeopencode npm scope (e.g. @kubeopencode/opencode-slack-plugin). The package.json name field must use this scoped format.
OpenCode plugins run inside Bun's JavaScript runtime, not Node.js. Bun's CJS/ESM interop differs from Node.js in ways that cause silent failures for CJS packages that use Object.defineProperty (getter/setter) for their exports.
Packages like @slack/web-api and @slack/socket-mode are CJS modules that export classes via Object.defineProperty:
// @slack/web-api/dist/index.js
Object.defineProperty(exports, "WebClient", { enumerable: true, get: function() { return ... } });In Node.js, import { WebClient } from "@slack/web-api" works because Node creates a live binding to the getter. But Bun (which OpenCode uses internally to load plugins via await import()) creates a namespace object that drops getter-defined properties. This means:
| Import style | Node.js | Bun (OpenCode runtime) |
|---|---|---|
import { WebClient } |
✅ | ❌ undefined |
import * as M; M.WebClient |
✅ | ❌ undefined |
const { WebClient } = await import(...) |
✅ | ❌ undefined |
createRequire()(...).WebClient |
✅ | ❌ require() async module unsupported |
All approaches fail. The error manifests as:
undefined is not a constructor (evaluating 'new WebClient(botToken)')
Always bundle CJS dependencies into the plugin output. This eliminates runtime CJS/ESM interop entirely — tsup's bundler resolves all exports at build time using __commonJS wrappers that correctly handle Object.defineProperty.
Configuration:
-
Move CJS dependencies from
dependenciestodevDependenciesinpackage.json:{ "dependencies": {}, "devDependencies": { "@slack/web-api": "^7.13.0", "@slack/socket-mode": "^2.0.5" } } -
tsup automatically bundles
devDependencies— no extra config needed. Dependencies listed independenciesare treated as external (left asimportstatements in the output). -
Use standard named imports in source:
import { WebClient } from "@slack/web-api" import { SocketModeClient } from "@slack/socket-mode"
Trade-off: The bundle grows from ~17KB to ~708KB when including @slack/web-api. This is acceptable for a plugin that runs once per Agent pod.
The ws library (used by @slack/socket-mode for WebSocket connections) depends on Node.js built-in modules (http, https, net, tls, etc.). When bundled with tsup, ws uses a __require polyfill at runtime that does not map to Bun's native implementations. This causes:
[ERROR] socket-mode:SlackWebSocket WebSocket error occurred: Unexpected server response: 101
HTTP 101 is the WebSocket upgrade response — ws treats it as an error because it's using the polyfilled http/https modules instead of Bun's native WebSocket.
Solution: Mark ws and all Node.js built-in modules as external in tsup. This forces runtime resolution via Bun's compatibility layer, which provides native ws support:
// tsup.config.ts
import { defineConfig } from "tsup"
export default defineConfig({
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
clean: true,
noExternal: ["@slack/web-api", "@slack/socket-mode", "@slack/types", "@slack/logger"],
external: ["ws", "http", "https", "net", "tls", "crypto", "stream", "events", "url", "zlib", "bufferutil", "utf-8-validate"],
})Keep @slack/* in dependencies so that plugin-init installs them (and their ws dependency) via npm. The noExternal setting ensures @slack/* source is bundled, while external ensures ws and Node.js builtins are resolved at runtime by Bun.
Detection: If a bundled plugin uses WebSocket connections and fails with "Unexpected server response: 101", check whether ws was bundled by searching for require_ws or __require("http") in the output. If found, add ws and Node.js builtins to the external list.
If a plugin dependency uses CJS ("type" is not "module" or absent in package.json, main points to a .js file without ESM exports), and uses Object.defineProperty for exports, it must be bundled. Check with:
# Does the package use Object.defineProperty for exports?
grep "Object.defineProperty(exports" node_modules/<pkg>/dist/index.jsIf you see Object.defineProperty(exports, "ClassName", the package has this issue and must be bundled.
- TypeScript, ESM (
"type": "module") - Use
@opencode-ai/pluginas a peer dependency - Prefer
constoverlet; use early returns over else blocks - Avoid
try/catchwhen possible; use.catch(() => {})for best-effort operations - Keep each plugin in a single file unless complexity demands splitting
- English comments only
- CJS dependencies must be bundled (see CJS/ESM Interop section above)