This guide covers everything you need to build, test, and distribute plugins for tinycode 2.0.
Plugins are standalone Go binaries that communicate with tinycode over JSON-RPC 2.0 via stdin/stdout. Each plugin is a separate process spawned by the tinycode plugin manager. Plugins can:
- Register custom tools that the LLM can invoke during sessions
- Hook into session lifecycle events (start, end)
- Intercept permission requests
- Inject environment variables into shell commands
- Observe and modify tool execution (before and after)
- Clean up resources on shutdown
The public SDK lives in pkg/plugin/. Import it as:
import "github.com/bobbyjohnstx/tinycode/pkg/plugin"The SDK provides three core types:
plugin.Plugin-- the plugin definition (ID, tools, hooks)plugin.ToolDef-- a tool exposed to the LLMplugin.HookHandlers-- optional lifecycle callbacks
A minimal plugin that provides a single tool:
package main
import (
"context"
"encoding/json"
"fmt"
"github.com/bobbyjohnstx/tinycode/pkg/plugin"
)
func main() {
plugin.Run(plugin.Plugin{
ID: "greet",
Tools: []plugin.ToolDef{{
Name: "greet",
Description: "Greet someone by name",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"name": map[string]any{
"type": "string",
"description": "The name to greet",
},
},
"required": []string{"name"},
},
Execute: func(ctx context.Context, args json.RawMessage, tc plugin.ToolContext) (string, error) {
var input struct {
Name string `json:"name"`
}
if err := json.Unmarshal(args, &input); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
return fmt.Sprintf("Hello, %s!", input.Name), nil
},
}},
})
}Build and install (binary name must match the config plugin name):
# from your plugin module root
mkdir -p ~/.config/tinycode/plugins
go build -o ~/.config/tinycode/plugins/greet .Or put tinycode-plugin-greet on your PATH. Add to your tinycode config (~/.config/tinycode/tinycode.json):
{
"plugins": ["greet"]
}Restart tinycode. The tool is registered as plugin__greet__greet (format: plugin__{pluginName}__{toolName}) and is available to the LLM.
plugin.Run() is the entry point for all plugins. It:
- Reads an
initializerequest from stdin - Responds with a manifest declaring the plugin's tools and hooks
- Enters a dispatch loop, routing
tool/callandhook/invokerequests to handlers - Calls the
Disposehook on clean shutdown (stdin closed)
type Plugin struct {
ID string
Tools []ToolDef
Hooks HookHandlers
}
func Run(p Plugin)Run blocks until stdin is closed or an unrecoverable error occurs. On error, it prints to stderr and exits with code 1.
Tools are functions the LLM can invoke during a session. Each tool has a name, description, JSON Schema parameters, and an execute function.
type ToolDef struct {
Name string
Description string
Parameters map[string]any
Execute func(ctx context.Context, args json.RawMessage, tc ToolContext) (string, error)
}| Field | Purpose |
|---|---|
Name |
Tool name within the plugin. On load, tinycode registers it as plugin__{pluginName}__{toolName} for the LLM. |
Description |
Human-readable description shown to the LLM. |
Parameters |
JSON Schema describing the tool's input. Use standard JSON Schema with type, properties, and required. |
Execute |
The function called when the LLM invokes the tool. Receives raw JSON args (wire field is "args", not "arguments") and a ToolContext. Returns a string result or an error. |
type ToolContext struct {
SessionID string `json:"sessionId"`
Directory string `json:"directory"`
}The ToolContext provides the current session ID and working directory.
Parameters use standard JSON Schema. Define them as map[string]any:
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"query": map[string]any{
"type": "string",
"description": "The search query",
},
"limit": map[string]any{
"type": "number",
"description": "Maximum number of results",
},
},
"required": []string{"query"},
},Return an error from Execute to signal failure. The error message is sent back to the LLM as the tool result with IsError: true:
Execute: func(ctx context.Context, args json.RawMessage, tc plugin.ToolContext) (string, error) {
var input myArgs
if err := json.Unmarshal(args, &input); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
result, err := doWork(input)
if err != nil {
return "", fmt.Errorf("operation failed: %w", err)
}
return result, nil
},notify ships as an in-process builtin (internal/plugin/builtin_notify.go), not as cmd/plugin-notify. A tool-only external plugin has this shape:
func newPlugin() plugin.Plugin {
return plugin.Plugin{
ID: "notify",
Tools: []plugin.ToolDef{{
Name: "notify",
Description: "Send a desktop notification",
Parameters: map[string]any{
"type": "object",
"properties": map[string]any{
"title": map[string]any{"type": "string", "description": "Notification title"},
"message": map[string]any{"type": "string", "description": "Notification body"},
},
"required": []string{"title", "message"},
},
Execute: executeNotify,
}},
}
}Hooks let plugins observe and react to events in the tinycode session lifecycle.
type HookHandlers struct {
SessionStart func(ctx context.Context, event SessionStartEvent) error
SessionEnd func(ctx context.Context, event SessionEndEvent) error
PermissionAsk func(ctx context.Context, input PermissionInput) (*PermissionOutput, error)
ShellEnv func(ctx context.Context, input ShellEnvInput) (*ShellEnvOutput, error)
ToolExecBefore func(ctx context.Context, input ToolExecBeforeInput) error
ToolExecAfter func(ctx context.Context, input ToolExecAfterInput) (*ToolExecAfterOutput, error)
Dispose func(ctx context.Context) error
}All fields are optional. Set only the hooks your plugin needs.
Fires when a new session is created.
type SessionStartEvent struct {
SessionID string `json:"sessionId"`
Directory string `json:"directory"`
}SessionStart: func(ctx context.Context, event plugin.SessionStartEvent) error {
log.Printf("Session started: %s in %s", event.SessionID, event.Directory)
return nil
},Fires when a session is destroyed.
type SessionEndEvent struct {
SessionID string `json:"sessionId"`
}SessionEnd: func(ctx context.Context, event plugin.SessionEndEvent) error {
return cleanup(event.SessionID)
},Fires when a tool requests permission. Return a PermissionOutput to auto-allow or auto-deny:
type PermissionInput struct {
SessionID string `json:"sessionId"`
ToolName string `json:"toolName"`
ToolArgs string `json:"toolArgs"`
Permission string `json:"permission"`
}
type PermissionOutput struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
}PermissionAsk: func(ctx context.Context, input plugin.PermissionInput) (*plugin.PermissionOutput, error) {
// Auto-allow read operations
if input.ToolName == "read" {
return &plugin.PermissionOutput{Allowed: true}, nil
}
// Return nil to fall through to the default permission prompt
return nil, nil
},Fires before shell commands execute. Return a ShellEnvOutput to inject environment variables:
type ShellEnvInput struct {
SessionID string `json:"sessionId"`
Directory string `json:"directory"`
Env map[string]string `json:"env,omitempty"`
}
type ShellEnvOutput struct {
Env map[string]string `json:"env"`
}ShellEnv: func(ctx context.Context, input plugin.ShellEnvInput) (*plugin.ShellEnvOutput, error) {
return &plugin.ShellEnvOutput{
Env: map[string]string{
"MY_PLUGIN_VAR": "some-value",
},
}, nil
},Fires before any tool executes. Cannot modify args from this hook. Returning a
non-nil error aborts the tool (same as a shell tool.execute.before hook with
a non-zero exit).
type ToolExecBeforeInput struct {
SessionID string `json:"sessionId"`
ToolName string `json:"toolName"`
ToolArgs string `json:"toolArgs"`
}ToolExecBefore: func(ctx context.Context, input plugin.ToolExecBeforeInput) error {
if input.ToolName == "bash" {
return fmt.Errorf("bash blocked by policy")
}
log.Printf("Tool %s called in session %s", input.ToolName, input.SessionID)
return nil
},Fires after any tool executes. Can modify the output by returning a ToolExecAfterOutput:
type ToolExecAfterInput struct {
SessionID string `json:"sessionId"`
ToolName string `json:"toolName"`
Output string `json:"output"`
IsError bool `json:"isError"`
}
type ToolExecAfterOutput struct {
Output string `json:"output"`
IsError bool `json:"isError"`
}ToolExecAfter: func(ctx context.Context, input plugin.ToolExecAfterInput) (*plugin.ToolExecAfterOutput, error) {
// Truncate very long outputs
if len(input.Output) > 10000 {
return &plugin.ToolExecAfterOutput{
Output: input.Output[:10000] + "\n[truncated]",
}, nil
}
// Return nil to pass through the original output unchanged
return nil, nil
},Fires on clean shutdown (stdin closed). Use this to flush data, close connections, or release resources:
Dispose: func(ctx context.Context) error {
return db.Close()
},Most real plugins combine tools and hooks. The telemetry plugin (cmd/plugin-telemetry/) is a good example:
func newPlugin() plugin.Plugin {
s := &state{}
return plugin.Plugin{
ID: "telemetry",
Tools: buildTools(s),
Hooks: buildHooks(s),
}
}It provides two tools (telemetry_report, telemetry_query) and uses hooks (SessionStart, ToolExecAfter, SessionEnd, Dispose) to track tool call metrics in a SQLite database:
- SessionStart -- records when sessions begin
- ToolExecAfter -- buffers tool call records
- SessionEnd -- flushes buffered records to the database
- Dispose -- closes the database connection
This pattern of shared mutable state (&state{}) passed to both tool and hook builders is common in plugins that need coordination between tools and lifecycle events.
Plugins communicate with tinycode over JSON-RPC 2.0 via stdin/stdout. Each message is a single JSON line.
- tinycode sends an
initializerequest:
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"version":"2.0","directory":"/path/to/project"}}- The plugin responds with its manifest:
{"jsonrpc":"2.0","id":1,"result":{"id":"my-plugin","tools":[{"name":"greet","description":"Greet someone","inputSchema":{...}}],"hooks":["session.start"]}}{"jsonrpc":"2.0","id":2,"method":"tool/call","params":{"name":"greet","args":{"name":"World"},"context":{"sessionId":"s1","directory":"/tmp"}}}Response:
{"jsonrpc":"2.0","id":2,"result":{"content":"Hello, World!","isError":false}}{"jsonrpc":"2.0","id":3,"method":"hook/invoke","params":{"name":"session.start","input":{"sessionId":"s1","directory":"/tmp"}}}Response:
{"jsonrpc":"2.0","id":3,"result":{"output":null}}Hooks that return output (PermissionAsk, ShellEnv, ToolExecAfter) include the output in the output field.
Messages with no id field are notifications and receive no response.
The SDK includes test patterns in pkg/plugin/plugin_test.go. The core approach is to call run() directly with mock stdin/stdout buffers.
func TestMyPlugin(t *testing.T) {
p := newPlugin()
// Build JSON-RPC requests
initParams, _ := json.Marshal(plugin.InitializeParams{
Version: "2.0",
Directory: "/tmp",
})
toolParams, _ := json.Marshal(plugin.ToolCallParams{
Name: "greet",
Args: json.RawMessage(`{"name":"World"}`),
Context: plugin.ToolContext{SessionID: "s1", Directory: "/tmp"},
})
// Write requests to stdin buffer
var input bytes.Buffer
writeRequest(&input, plugin.JSONRPCRequest{
JSONRPC: "2.0", ID: 1, Method: "initialize", Params: initParams,
})
writeRequest(&input, plugin.JSONRPCRequest{
JSONRPC: "2.0", ID: 2, Method: "tool/call", Params: toolParams,
})
// Run the plugin
var output bytes.Buffer
err := run(context.Background(), p, &input, &output)
if err != nil {
t.Fatalf("run error: %v", err)
}
// Parse and verify responses
responses := parseResponses(t, output.String())
// ... assert on responses
}
func writeRequest(buf *bytes.Buffer, req plugin.JSONRPCRequest) {
data, _ := json.Marshal(req)
buf.Write(data)
buf.WriteByte('\n')
}func TestSessionStartHook(t *testing.T) {
var receivedID string
p := plugin.Plugin{
ID: "test",
Hooks: plugin.HookHandlers{
SessionStart: func(_ context.Context, event plugin.SessionStartEvent) error {
receivedID = event.SessionID
return nil
},
},
}
hookInput, _ := json.Marshal(plugin.SessionStartEvent{
SessionID: "ses-123",
Directory: "/tmp",
})
hookParams, _ := json.Marshal(plugin.HookParams{
Name: "session.start",
Input: hookInput,
})
// Send initialize + hook/invoke, verify receivedID == "ses-123"
}When tinycode loads a plugin by name, it searches for the binary in this order:
- Config directory:
~/.config/tinycode/plugins/<name> - PATH: looks for
tinycode-plugin-<name>on the system PATH - Registry: checks the built-in registry for install instructions
The plugin name does not need to be in the curated registry; any binary found by this search can be loaded.
Option 1: Build to the config directory
go build -o ~/.config/tinycode/plugins/my-plugin ./cmd/plugin-my-pluginOption 2: Install to PATH
go install github.com/example/tinycode-plugin-my-plugin@latestThe binary name must follow the tinycode-plugin-<name> convention.
Option 3: Registry plugins
Some plugins are listed in the built-in registry. If a plugin is in the registry but not installed, tinycode reports the error with the repository URL for installation.
30 binaries ship in cmd/plugin-*/. Names and descriptions match internal/plugin/registry.go. notify, code-review, handoff, and context-pruning are in-process builtins.
| Directory | ID | Description |
|---|---|---|
plugin-aap-bridge |
aap-bridge |
Ansible Automation Platform bridge |
plugin-audit-logs |
audit-logs |
API audit log analysis |
plugin-container-linter |
container-linter |
Containerfile linting and bootc validation |
plugin-etcd-diag |
etcd-diag |
etcd diagnostics and snapshot inspection |
plugin-ingress-inspect |
ingress-inspect |
HAProxy/Ingress inspection |
plugin-insights |
insights |
OpenShift Insights archive analysis |
plugin-lightwell |
lightwell |
Red Hat Lightwell package security |
plugin-log-sanitizer |
log-sanitizer |
Sanitize sensitive data from logs |
plugin-ocp-context-injection |
ocp-context-injection |
OpenShift cluster context injection |
plugin-ocp-must-gather |
ocp-must-gather |
Must-gather offline analysis |
plugin-ocp-obs-logging |
ocp-obs-logging |
Loki, Tempo, and NetObserv |
plugin-ocp-obs-metrics |
ocp-obs-metrics |
PromQL, alerts, and silencing |
plugin-ocp-odf |
ocp-odf |
OpenShift Data Foundation storage health |
plugin-ocp-virt |
ocp-virt |
OpenShift Virtualization VM lifecycle |
plugin-pilot |
pilot |
Autonomous agent pilot mode |
plugin-quay |
quay |
Quay container registry |
plugin-rh-api-catalog |
rh-api-catalog |
Red Hat API catalog |
plugin-rh-dev-content |
rh-dev-content |
Red Hat developer content |
plugin-rh-ecosystem-catalog |
rh-ecosystem-catalog |
Ecosystem catalog via Pyxis |
plugin-rhacm |
rhacm |
ACM fleet management |
plugin-rhacs |
rhacs |
ACS security scanning |
plugin-rhdh |
rhdh |
Developer Hub catalog and APIs |
plugin-rhdp-provisioner |
rhdp-provisioner |
Developer platform provisioner |
plugin-rhoai-mlflow |
rhoai-mlflow |
MLflow experiment tracking and model registry |
plugin-rhoai-pipelines |
rhoai-pipelines |
RHOAI data science pipelines |
plugin-rhoai-serving |
rhoai-serving |
RHOAI model serving, evaluation, and TrustyAI |
plugin-safety-net |
safety-net |
Pre-execution safety checks |
plugin-satellite |
satellite |
Red Hat Satellite |
plugin-tekton |
tekton |
Tekton pipelines |
plugin-telemetry |
telemetry |
Usage telemetry and analytics |
Build all plugins (writes dist/plugins/plugin-*):
make build-plugins
# e.g. dist/plugins/plugin-safety-net → install with:
# tinycode plugin install safety-net --from dist/plugins/plugin-safety-netOr install each into the config directory:
for dir in cmd/plugin-*/; do
name=$(basename "$dir")
go build -o ~/.config/tinycode/plugins/${name#plugin-} ./$dir
donetype JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int64 `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *JSONRPCError `json:"error,omitempty"`
}
type JSONRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
type InitializeParams struct {
Version string `json:"version"`
Directory string `json:"directory"`
Options map[string]any `json:"options,omitempty"`
}
type InitializeResult struct {
ID string `json:"id"`
Tools []ToolManifest `json:"tools"`
Hooks []string `json:"hooks"`
}
type ToolManifest struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema,omitempty"`
}| Code | Meaning |
|---|---|
-32601 |
Unknown method |
-32602 |
Invalid params (bad tool call params, unknown tool) |
-32000 |
Hook execution error |