This guide covers everything you need to build, test, and publish plugins for tinycode.
Plugins extend tinycode with custom tools, LLM providers, authentication flows, and lifecycle hooks. A plugin is an npm package (or local file) that exports a PluginModule object. When tinycode loads the plugin, it calls the module's server function with a PluginInput context and receives back a Hooks object declaring what the plugin provides.
- Register custom tools that the LLM can invoke during sessions
- Add LLM providers with custom model discovery
- Intercept and modify chat messages, parameters, and headers before they reach the LLM
- React to session lifecycle events (start, end, switch, model change)
- Inject environment variables into shell commands
- Intercept tool execution (before and after)
- Modify tool definitions sent to the LLM
- Handle permission requests
- Add custom authentication flows (OAuth, API key)
- Stream and react to server events
- Modify the tinycode config at load time
import type { PluginModule } from "tinycode-plugin"
export default {
server: async (input, options) => {
// Return a Hooks object
return {
// ... hooks go here
}
},
} satisfies PluginModuleThe PluginModule type:
type PluginModule = {
id?: string // Optional unique identifier
server: Plugin // Required: the plugin entry point
tui?: never // Server plugins cannot also be TUI plugins
schema?: PluginSchema // Optional: zod schema to validate plugin options
}The current plugin API version is 1. Plugins can declare compatibility in their package.json:
{
"engines": {
"tinycode-plugin": "1"
}
}my-plugin/
package.json
src/
index.ts
package.json:
{
"name": "my-tinycode-plugin",
"version": "1.0.0",
"type": "module",
"exports": {
"./server": "./src/index.ts"
},
"dependencies": {
"tinycode-plugin": "latest"
}
}The ./server export is required for server-side plugins. If your plugin also extends the TUI, add a ./tui export pointing to a file that exports a TuiPlugin.
src/index.ts:
import type { PluginModule } from "tinycode-plugin"
import { tool } from "tinycode-plugin/tool"
export default {
server: async (input, options) => {
return {
tool: {
greet: tool({
description: "Greet someone by name",
args: {
name: tool.schema.string().describe("The name to greet"),
},
async execute(args) {
return `Hello, ${args.name}!`
},
}),
},
}
},
} satisfies PluginModuleUse a file:// path to install a plugin from your local filesystem:
tinycode plugin file:///path/to/my-pluginOr add it directly to your config file (~/.config/tinycode/config.json or .tinycode/config.json):
{
"plugin": [
"file:///path/to/my-plugin"
]
}Plugins installed via file:// paths are resolved directly without npm. This is the fastest way to iterate during development.
The server function receives a PluginInput object:
type PluginInput = {
client: TinycodeClient // SDK client connected to the running server
project: Project // Current project info (id, worktree, time)
directory: string // Current working directory
worktree: string // Project worktree root
serverUrl: URL // URL of the running tinycode server
$: BunShell // Bun shell for running commands
}The $ shell supports tagged template literals for running commands:
const result = await input.$`ls -la ${input.directory}`.text()All hooks are optional fields on the Hooks object returned by your plugin's server function. Hooks follow two patterns:
- Observer hooks receive an
inputobject (read-only context) and anoutputobject (mutable). Modifyoutputto affect behavior. - Registration hooks are objects or functions that register capabilities (tools, providers, auth).
Fires when a new session is created.
"session.start"?: (
input: { sessionID: string; parentID?: string; agent?: string },
output: {},
) => Promise<void>parentIDis set when the session is a subagent spawned by another session.agentis the agent name if the session was started with a specific agent.
"session.start": async (input) => {
console.log(`Session started: ${input.sessionID}`)
if (input.parentID) {
console.log(` Spawned by: ${input.parentID}`)
}
},Fires when a session is deleted.
"session.end"?: (
input: { sessionID: string },
output: {},
) => Promise<void>"session.end": async (input) => {
await cleanup(input.sessionID)
},Fires when the user switches to a different session.
"session.switch"?: (
input: { sessionID: string; previousSessionID?: string },
output: {},
) => Promise<void>"session.switch": async (input) => {
console.log(`Switched to ${input.sessionID} from ${input.previousSessionID}`)
},Fires when the model is changed for a session.
"session.model.change"?: (
input: {
sessionID: string
providerID: string
modelID: string
previousModelID?: string
},
output: {},
) => Promise<void>"session.model.change": async (input) => {
console.log(`Model changed to ${input.providerID}/${input.modelID}`)
},Fires when a new user message is received. Mutate output to modify the message or its parts before processing.
"chat.message"?: (
input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
variant?: string
},
output: { message: UserMessage; parts: Part[] },
) => Promise<void>"chat.message": async (input, output) => {
// Prepend context to every message
output.parts.push({
type: "text",
text: `[Context: project=${input.agent}]`,
})
},Modify the parameters sent to the LLM for a chat request. Mutate output fields to change temperature, token limits, and other model parameters.
"chat.params"?: (
input: {
sessionID: string
agent: string
model: Model
provider: ProviderContext
message: UserMessage
},
output: {
temperature: number
topP: number
topK: number
maxOutputTokens: number | undefined
options: Record<string, any>
},
) => Promise<void>The provider field contains:
type ProviderContext = {
source: "env" | "config" | "custom" | "api"
info: Provider
options: Record<string, any>
}"chat.params": async (input, output) => {
// Force low temperature for the architect agent
if (input.agent === "architect") {
output.temperature = 0.1
}
},Add or modify HTTP headers sent with LLM API requests.
"chat.headers"?: (
input: {
sessionID: string
agent: string
model: Model
provider: ProviderContext
message: UserMessage
},
output: { headers: Record<string, string> },
) => Promise<void>"chat.headers": async (input, output) => {
output.headers["X-Custom-Header"] = "my-value"
},Register custom tools. This is an object mapping tool names to ToolDefinition objects created with the tool() helper. See the Tool API section for details.
tool?: {
[key: string]: ToolDefinition
}tool: {
"my-tool": tool({
description: "Does something useful",
args: {
query: tool.schema.string().describe("Search query"),
},
async execute(args, context) {
return `Result: ${args.query}`
},
}),
},Fires before a tool executes. Mutate output.args to modify the arguments passed to the tool.
"tool.execute.before"?: (
input: { tool: string; sessionID: string; callID: string },
output: { args: any },
) => Promise<void>"tool.execute.before": async (input, output) => {
if (input.tool === "shell") {
console.log(`Shell command: ${JSON.stringify(output.args)}`)
}
},Fires after a tool executes. Mutate output to modify the tool's result before it reaches the LLM.
"tool.execute.after"?: (
input: { tool: string; sessionID: string; callID: string; args: any },
output: {
title: string
output: string
metadata: any
},
) => Promise<void>"tool.execute.after": async (input, output) => {
// Truncate very long tool outputs
if (output.output.length > 10000) {
output.output = output.output.slice(0, 10000) + "\n[truncated]"
}
},Modify tool definitions (description and parameters) before they are sent to the LLM. Useful for customizing how tools are presented to models.
"tool.definition"?: (
input: { toolID: string },
output: { description: string; parameters: any },
) => Promise<void>"tool.definition": async (input, output) => {
if (input.toolID === "shell") {
output.description += "\nPrefer using absolute paths."
}
},Intercept permission requests. Mutate output.status to auto-allow or auto-deny permissions.
"permission.ask"?: (
input: Permission,
output: { status: "ask" | "deny" | "allow" },
) => Promise<void>"permission.ask": async (input, output) => {
// Auto-allow read operations
if (input.tool === "read") {
output.status = "allow"
}
},Inject environment variables into shell commands executed by tinycode.
"shell.env"?: (
input: { cwd: string; sessionID?: string; callID?: string },
output: { env: Record<string, string> },
) => Promise<void>"shell.env": async (input, output) => {
output.env["MY_PLUGIN_VAR"] = "some-value"
output.env["NODE_OPTIONS"] = "--max-old-space-size=4096"
},Fires before a slash command executes. Mutate output.parts to inject additional context or modify the command input.
"command.execute.before"?: (
input: { command: string; sessionID: string; arguments: string },
output: { parts: Part[] },
) => Promise<void>"command.execute.before": async (input, output) => {
if (input.command === "ask") {
output.parts.push({
type: "text",
text: "Additional context for the agent.",
})
}
},Register a custom authentication flow for a provider. Supports both OAuth and API key methods.
auth?: AuthHookThe AuthHook type:
type AuthHook = {
provider: string
loader?: (auth: () => Promise<Auth>, provider: Provider) => Promise<Record<string, any>>
methods: AuthMethod[]
}Each method is either type: "oauth" or type: "api":
OAuth method:
{
type: "oauth"
label: string
prompts?: AuthPrompt[]
authorize(inputs?: Record<string, string>): Promise<AuthOAuthResult>
}API key method:
{
type: "api"
label: string
prompts?: AuthPrompt[]
authorize?(inputs?: Record<string, string>): Promise<
| { type: "success"; key: string; provider?: string; metadata?: Record<string, string> }
| { type: "failed" }
>
}Both method types support interactive prompts:
type AuthPrompt =
| {
type: "text"
key: string
message: string
placeholder?: string
validate?: (value: string) => string | undefined
when?: { key: string; op: "eq" | "neq"; value: string }
}
| {
type: "select"
key: string
message: string
options: Array<{ label: string; value: string; hint?: string }>
when?: { key: string; op: "eq" | "neq"; value: string }
}auth: {
provider: "my-provider",
methods: [
{
type: "api",
label: "API Key",
prompts: [
{
type: "text",
key: "apiKey",
message: "Enter your API key",
placeholder: "sk-...",
validate: (value) =>
value.startsWith("sk-") ? undefined : "Key must start with sk-",
},
],
async authorize(inputs) {
if (!inputs?.apiKey) return { type: "failed" }
return {
type: "success",
key: inputs.apiKey,
}
},
},
],
},Register a custom LLM provider with model discovery.
provider?: ProviderHooktype ProviderHook = {
id: string
models?: (provider: ProviderV2, ctx: ProviderHookContext) => Promise<Record<string, ModelV2>>
}
type ProviderHookContext = {
auth?: Auth
}provider: {
id: "my-provider",
async models(provider, ctx) {
return {
"my-model": {
id: "my-model",
name: "My Model",
// ... model configuration
},
}
},
},Modify the tinycode configuration at load time. Receives the full config object (minus the plugin field).
config?: (input: Config) => Promise<void>The Config type is Omit<SDKConfig, "plugin"> & { plugin?: Array<string | [string, PluginOptions]> }.
config: async (config) => {
// Modify config at load time
},Receive all server events. This is a firehose of every event emitted by the tinycode server.
event?: (input: { event: Event }) => Promise<void>event: async ({ event }) => {
if (event.type === "session.updated") {
console.log(`Session updated: ${event.properties.sessionID}`)
}
},Called when the plugin is being unloaded. Use this to clean up resources, close connections, or flush data.
dispose?: () => Promise<void>dispose: async () => {
await db.close()
console.log("Plugin cleaned up")
},These hooks are prefixed with experimental. and may change in future versions without a major version bump. Use them with caution.
Transform the full message history before it is sent to the LLM. Allows rewriting, filtering, or reordering messages.
"experimental.chat.messages.transform"?: (
input: {},
output: {
messages: {
info: Message
parts: Part[]
}[]
},
) => Promise<void>Transform the system prompt before it is sent to the LLM.
"experimental.chat.system.transform"?: (
input: { sessionID?: string; model: Model },
output: { system: string[] },
) => Promise<void>"experimental.chat.system.transform": async (input, output) => {
output.system.push("Always respond in bullet points.")
},Called before session compaction starts. Allows customizing or replacing the compaction prompt.
"experimental.session.compacting"?: (
input: { sessionID: string },
output: { context: string[]; prompt?: string },
) => Promise<void>context: additional context strings appended to the default compaction prompt.prompt: if set, replaces the default compaction prompt entirely.
Called after compaction succeeds and before a synthetic user auto-continue message is added.
"experimental.compaction.autocontinue"?: (
input: {
sessionID: string
agent: string
model: Model
provider: ProviderContext
message: UserMessage
overflow: boolean
},
output: { enabled: boolean },
) => Promise<void>Set output.enabled = false to skip the synthetic user "continue" turn after compaction.
Called when a text part is complete. Allows post-processing of generated text.
"experimental.text.complete"?: (
input: { sessionID: string; messageID: string; partID: string },
output: { text: string },
) => Promise<void>Tools are the primary way plugins expose functionality to the LLM. Use the tool() helper from tinycode-plugin/tool to define tools with validated arguments and typed execution.
import { tool } from "tinycode-plugin/tool"
const myTool = tool({
description: "Search a knowledge base",
args: {
query: tool.schema.string().describe("The search query"),
limit: tool.schema.number().optional().describe("Max results to return"),
},
async execute(args, context) {
const results = await search(args.query, args.limit ?? 10)
return {
title: `Found ${results.length} results`,
output: results.map((r) => r.summary).join("\n"),
}
},
})tool.schema is a re-export of zod (z). Use it to define argument validation:
args: {
name: tool.schema.string().describe("User name"),
age: tool.schema.number().int().min(0).describe("User age"),
role: tool.schema.enum(["admin", "user"]).describe("User role"),
tags: tool.schema.array(tool.schema.string()).optional().describe("Tags"),
}Every argument field should include .describe() to document the parameter for the LLM.
The execute function receives a ToolContext as its second argument:
type ToolContext = {
sessionID: string // Current session ID
messageID: string // Current message ID
agent: string // Current agent name
directory: string // Current project directory for this session
worktree: string // Project worktree root
abort: AbortSignal // Abort signal for cancellation
metadata(input: { // Report metadata back to the UI
title?: string
metadata?: Record<string, any>
}): void
ask(input: AskInput): Promise<void> // Request user permission
progress: (message: string) => void // Emit progress during execution
messages: () => Promise<ReadonlyArray<{ role: string; content: string }>> // Conversation history
sessionInfo: () => Promise<{ id: string; model: string; agent: string }> // Session metadata
}The ask function requests permission from the user:
type AskInput = {
permission: string // Permission identifier
patterns: string[] // Patterns this permission covers
always: string[] // Patterns to auto-allow in future
metadata: Record<string, any> // Additional context for the permission dialog
}Use metadata() to update the tool's display title and pass structured data back:
async execute(args, context) {
context.metadata({ title: `Searching for "${args.query}"...` })
const results = await search(args.query)
context.metadata({
title: `Found ${results.length} results`,
metadata: { count: results.length },
})
return results.map((r) => r.text).join("\n")
}Tools can return either a plain string or a structured result:
type ToolResult =
| string
| {
title?: string // Display title in the UI
output: string // Text output sent to the LLM
metadata?: Record<string, any> // Structured metadata
attachments?: ToolAttachment[] // File attachments
}Attach files to tool results:
type ToolAttachment = {
type: "file"
mime: string // MIME type (e.g. "image/png", "application/json")
url: string // File URL or data URI
filename?: string // Optional display filename
}async execute(args) {
const chart = await generateChart(args.data)
return {
output: "Chart generated successfully.",
attachments: [
{
type: "file",
mime: "image/png",
url: `file://${chart.path}`,
filename: "chart.png",
},
],
}
}Users configure plugins in their tinycode config file (~/.config/tinycode/config.json for global, or .tinycode/config.json for project-level):
{
"plugin": [
"my-plugin-name",
["my-other-plugin", { "apiKey": "sk-...", "verbose": true }]
]
}Plugins listed as a plain string receive no options. Plugins listed as a [name, options] tuple receive the options object as the second argument to the server function.
Plugins can export a schema field on their PluginModule to validate options before the plugin loads. The schema is a zod type:
import { z } from "zod"
import type { PluginModule } from "tinycode-plugin"
export default {
schema: z.object({
apiKey: z.string().min(1, "API key is required"),
verbose: z.boolean().optional().default(false),
}),
server: async (input, options) => {
// options is validated against the schema before reaching here
const { apiKey, verbose } = options as { apiKey: string; verbose: boolean }
return {}
},
} satisfies PluginModuleWhen validation fails, tinycode reports the error and skips loading the plugin.
The tinycode-plugin/test module provides utilities for testing plugins without a running tinycode server.
Creates a mock PluginInput with sensible defaults. All fields can be overridden:
import { createMockPluginInput } from "tinycode-plugin/test"
const input = createMockPluginInput({
directory: "/my/project",
worktree: "/my/project",
})Default values:
client: empty object stubproject:{ id: "test-project", worktree: "/tmp/tinycode-plugin-test", time: { created: Date.now() } }directory:"/tmp/tinycode-plugin-test"worktree:"/tmp/tinycode-plugin-test"serverUrl:new URL("http://localhost:4096")$: no-op shell that returns empty results
Creates a mock ToolContext for testing tool execution:
import { createMockToolContext } from "tinycode-plugin/test"
const context = createMockToolContext({
sessionID: "my-session",
agent: "build",
})Default values:
sessionID:"test-session"messageID:"test-message"agent:"test-agent"directory:"/tmp/tinycode-plugin-test"worktree:"/tmp/tinycode-plugin-test"abort:AbortSignal.abort()metadata: no-op functionask: no-op async function
Loads a PluginModule with mock input and returns the resolved hooks plus a typed invoke helper:
import { createTestHarness } from "tinycode-plugin/test"
import plugin from "./index"
const { hooks, invoke } = await createTestHarness(plugin, {
input: { directory: "/my/project" },
pluginOptions: { apiKey: "test-key" },
})The invoke function calls a hook by name with full type checking:
await invoke(
"session.start",
{ sessionID: "s1", parentID: undefined, agent: "build" },
{},
)If the hook is not registered, invoke throws an error.
import { describe, it, expect } from "bun:test"
import { createTestHarness, createMockToolContext } from "tinycode-plugin/test"
import plugin from "../src/index"
describe("my-plugin", () => {
it("registers the greet tool", async () => {
const { hooks } = await createTestHarness(plugin)
expect(hooks.tool).toBeDefined()
expect(hooks.tool!.greet).toBeDefined()
})
it("greet tool returns greeting", async () => {
const { hooks } = await createTestHarness(plugin)
const context = createMockToolContext()
const result = await hooks.tool!.greet.execute({ name: "World" }, context)
expect(result).toBe("Hello, World!")
})
it("session.start hook fires", async () => {
const sessions: string[] = []
// Test with a plugin that tracks sessions
const { invoke } = await createTestHarness(plugin)
await invoke(
"session.start",
{ sessionID: "test-1", agent: "build" },
{},
)
})
})Your package.json must include:
-
exportswith a./serverentry pointing to your plugin module:{ "exports": { "./server": "./src/index.ts" } }If your plugin has a TUI component, also include
./tui:{ "exports": { "./server": "./src/index.ts", "./tui": "./src/tui.ts" } } -
tinycode-pluginas a dependency for type support and thetool()helper. -
type: "module"since tinycode plugins are ESM.
npm publishUsers install your plugin with:
tinycode plugin your-package-nameTinycode maintains a curated plugin registry. Registry plugins can be installed by short name instead of full npm specifier:
tinycode plugin azure # Resolves to @tinycode/plugin-azure
tinycode plugin github-copilot # Resolves to @tinycode/plugin-github-copilotUsers can search the registry:
tinycode plugin-search provider
tinycode plugin-search linterTo submit your plugin to the registry, open a pull request adding an entry to src/plugin/registry.json:
{
"name": "your-plugin",
"npm": "@yourorg/tinycode-plugin-name",
"description": "Short description of what it does",
"author": "your-name",
"tags": ["relevant", "tags"]
}A full working plugin that provides a tool, reacts to session events, validates its configuration, and cleans up on dispose.
package.json:
{
"name": "tinycode-plugin-metrics",
"version": "1.0.0",
"type": "module",
"exports": {
"./server": "./src/index.ts"
},
"dependencies": {
"tinycode-plugin": "latest"
}
}src/index.ts:
import { z } from "zod"
import type { PluginModule, PluginInput, PluginOptions } from "tinycode-plugin"
import { tool } from "tinycode-plugin/tool"
type SessionMetrics = {
startTime: number
toolCalls: number
modelChanges: number
}
export default {
schema: z.object({
endpoint: z.string().url("Must be a valid URL"),
verbose: z.boolean().optional().default(false),
}),
server: async (input: PluginInput, options?: PluginOptions) => {
const config = options as { endpoint: string; verbose: boolean }
const sessions = new Map<string, SessionMetrics>()
async function flush(sessionID: string) {
const metrics = sessions.get(sessionID)
if (!metrics) return
if (config.verbose) {
console.log(`Flushing metrics for ${sessionID}:`, metrics)
}
await fetch(config.endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionID, ...metrics }),
}).catch(() => {})
sessions.delete(sessionID)
}
return {
tool: {
metrics: tool({
description: "Show session metrics (tool call count, duration, model changes)",
args: {
sessionID: tool.schema.string().optional().describe(
"Session ID to query. Defaults to current session."
),
},
async execute(args, context) {
const id = args.sessionID ?? context.sessionID
const metrics = sessions.get(id)
if (!metrics) {
return "No metrics found for this session."
}
const duration = Math.round((Date.now() - metrics.startTime) / 1000)
return {
title: "Session Metrics",
output: [
`Duration: ${duration}s`,
`Tool calls: ${metrics.toolCalls}`,
`Model changes: ${metrics.modelChanges}`,
].join("\n"),
metadata: { duration, ...metrics },
}
},
}),
},
"session.start": async (input) => {
sessions.set(input.sessionID, {
startTime: Date.now(),
toolCalls: 0,
modelChanges: 0,
})
},
"session.end": async (input) => {
await flush(input.sessionID)
},
"session.model.change": async (input) => {
const metrics = sessions.get(input.sessionID)
if (metrics) {
metrics.modelChanges += 1
}
},
"tool.execute.after": async (input) => {
const metrics = sessions.get(input.sessionID)
if (metrics) {
metrics.toolCalls += 1
}
},
dispose: async () => {
// Flush all remaining sessions
for (const sessionID of sessions.keys()) {
await flush(sessionID)
}
sessions.clear()
},
}
},
} satisfies PluginModuleInstall for development:
tinycode plugin file:///path/to/tinycode-plugin-metricsOr configure with options in ~/.config/tinycode/config.json:
{
"plugin": [
["tinycode-plugin-metrics", {
"endpoint": "https://metrics.example.com/collect",
"verbose": true
}]
]
}The following OpenCode plugins have been deprecated because their functionality is now built into tinycode:
| OpenCode Plugin | Built-in Replacement | Status |
|---|---|---|
opencode-openai-codex-auth |
Built-in Codex auth | Deprecated since 1.14 |
opencode-copilot-auth |
Built-in Copilot auth | Deprecated since 1.14 |
opencode-gitlab-auth |
Built-in GitLab auth | Deprecated since 1.18 |
opencode-poe-auth |
Built-in Poe auth | Deprecated since 1.18 |
If you have any of these plugins in your config.json under plugin_origins, you can safely remove them. tinycode silently skips deprecated plugins, so no action is required — but removing them avoids unnecessary startup warnings.
OpenCode plugins that use a different hook interface than tinycode's Hooks type are not supported. If you have a custom OpenCode plugin, migrate it to the tinycode plugin SDK (tinycode-plugin on npm). See the rest of this guide for the tinycode plugin API.