Status: Draft — for review by @MarlBurroW
Version: 0.1.0
Date: 2026-03-04
- Overview
- Plugin Types
- Plugin Structure
- Plugin API / SDK
- Plugin Lifecycle
- Plugin Distribution
- Security
- UI Integration
- Examples
- Migration Path
- Implementation Roadmap
KinBot has a rich set of built-in tools, providers, and channels. But users who want to add custom integrations (SMS, CRM, home automation, custom APIs) must either:
- Use the
register_tool/ custom tool system (limited to shell scripts) - Use MCP servers (separate process, protocol overhead)
- Fork and modify core code
A first-class plugin system that lets users drop a folder into plugins/ and get new tools, providers, channels, and hooks — with zero core code changes.
- Low barrier to entry — A plugin is a folder with a manifest and a TypeScript file. That's it.
- TypeScript-first — Plugins are TypeScript, compiled by Bun at load time. No separate build step required.
- Safe by default — Plugins declare permissions; users approve them before activation.
- Kin-scoped — Plugins can be enabled globally or per-Kin.
- Compatible — Built-in tools remain unchanged. Plugins use the same
ToolRegistrationpattern.
A single plugin can contribute one or more of these:
New tools available to Kins via the AI tool-calling mechanism. Uses the existing ToolRegistration interface — same create(ctx) factory, same Zod schemas, same availability array.
New LLM, embedding, image, or search providers. Implements the existing ProviderDefinition interface (testConnection, listModels).
New messaging platforms. Implements the existing ChannelAdapter interface (start, stop, sendMessage, validateConfig, getBotInfo).
Intercept lifecycle events using the existing HookRegistry. Available hooks:
| Hook | Fired When |
|---|---|
beforeChat |
Before a Kin processes a message |
afterChat |
After a Kin generates a response |
beforeToolCall |
Before any tool executes |
afterToolCall |
After any tool executes |
beforeCompacting |
Before conversation compaction |
afterCompacting |
After conversation compaction |
onTaskSpawn |
When a sub-task is created |
onCronTrigger |
When a cron job fires |
Plugins can also register custom hooks for inter-plugin communication.
plugins/
weather/
plugin.json # Manifest (required)
index.ts # Entry point (required)
README.md # Documentation (optional)
assets/ # Static assets, icons (optional)
icon.png
Plugins live in the plugins/ directory at the KinBot root (sibling to src/). Each plugin is a single folder.
{
"name": "weather",
"version": "1.0.0",
"description": "Get current weather and forecasts for any location",
"author": "Your Name",
"homepage": "https://github.com/you/kinbot-plugin-weather",
"license": "MIT",
"kinbot": ">=0.10.0",
"main": "index.ts",
"icon": "assets/icon.png",
"permissions": [
"http:api.openweathermap.org"
],
"config": {
"apiKey": {
"type": "string",
"label": "OpenWeatherMap API Key",
"description": "Get one at https://openweathermap.org/api",
"required": true,
"secret": true
},
"units": {
"type": "select",
"label": "Temperature Units",
"options": ["metric", "imperial"],
"default": "metric"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Unique identifier ([a-z0-9-]+) |
version |
string |
✅ | Semver version |
description |
string |
✅ | One-line description |
author |
string |
❌ | Author name or org |
homepage |
string |
❌ | URL to repo or docs |
license |
string |
❌ | SPDX license identifier |
kinbot |
string |
❌ | Semver range of compatible KinBot versions |
main |
string |
✅ | Entry point file (relative to plugin dir) |
icon |
string |
❌ | Path to icon (PNG/SVG, 128×128 recommended) |
permissions |
string[] |
❌ | Declared permissions (see §7) |
config |
object |
❌ | Configuration schema (see §3.3) |
The config object in plugin.json defines settings that are surfaced in the UI. Each key becomes a setting field.
Supported field types:
| Type | UI Widget | Extra Properties |
|---|---|---|
string |
Text input | placeholder, pattern |
number |
Number input | min, max, step |
boolean |
Toggle switch | — |
select |
Dropdown | options: string[] |
text |
Textarea | rows |
Common properties for all types:
label: string— Display labeldescription?: string— Help textrequired?: boolean— Defaultfalsedefault?: any— Default valuesecret?: boolean— Iftrue, value is stored encrypted alongside Vault secrets and masked in the UI
Fields with secret: true are:
- Stored encrypted in the KinBot database (same mechanism as provider API keys)
- Never exposed in API responses (replaced with
"••••••••") - Available to the plugin at runtime via
ctx.config.apiKey - Shown as password fields in the UI
The plugin's main file must default-export a function that receives a PluginContext and returns a PluginExports object:
import type { PluginContext, PluginExports } from 'kinbot/plugin'
export default function(ctx: PluginContext): PluginExports {
return {
tools: { /* ... */ },
providers: { /* ... */ },
channels: { /* ... */ },
hooks: { /* ... */ },
activate: async () => { /* called on enable */ },
deactivate: async () => { /* called on disable */ },
}
}Passed to the plugin init function. Provides access to KinBot services:
interface PluginContext {
/** Plugin config values (from UI settings, with secrets resolved) */
config: Record<string, any>
/** Plugin's own isolated logger */
log: Logger
/** Key-value storage scoped to this plugin */
storage: PluginStorage
/** Make HTTP requests (respects declared permissions) */
http: PluginHTTPClient
/** Access Kin memory (read/write, scoped to the Kin using this tool) */
memory: {
recall(query: string, kinId: string, limit?: number): Promise<MemoryEntry[]>
memorize(kinId: string, content: string, metadata?: Record<string, string>): Promise<string>
}
/** Send notifications to users */
notify: {
send(kinId: string, message: string): Promise<void>
}
/** Plugin metadata from manifest */
manifest: PluginManifest
}interface PluginExports {
/** Tools to register (keyed by tool name) */
tools?: Record<string, ToolRegistration>
/** Providers to register (keyed by provider type) */
providers?: Record<string, ProviderDefinition>
/** Channels to register (keyed by platform name) */
channels?: Record<string, ChannelAdapter>
/** Hooks to register */
hooks?: Partial<Record<HookName, HookHandler>>
/** Called when plugin is enabled */
activate?(): Promise<void>
/** Called when plugin is disabled (cleanup) */
deactivate?(): Promise<void>
}Persistent key-value store scoped to the plugin. Backed by SQLite (same DB as KinBot).
interface PluginStorage {
get<T = unknown>(key: string): Promise<T | null>
set<T = unknown>(key: string, value: T): Promise<void>
delete(key: string): Promise<void>
list(prefix?: string): Promise<string[]>
clear(): Promise<void>
}Thin wrapper around fetch that enforces permission checks.
interface PluginHTTPClient {
fetch(url: string, init?: RequestInit): Promise<Response>
}Only URLs matching declared permissions (http:*.example.com) are allowed. Attempts to access undeclared hosts throw a PermissionDeniedError.
- Direct filesystem access — No
fsmodule. UsePluginStorageinstead. - Direct database access — No raw SQL. Use provided APIs.
- Modify other plugins — No access to other plugin internals.
- Access process/env — No
process.env. Secrets come viactx.config. - Spawn processes — No
child_process. Usectx.httpfor external APIs. - Import core internals — Only the
kinbot/pluginSDK types are public API.
Note: For v1, isolation is convention-based (TypeScript module boundaries), not a true sandbox. Plugins run in the same process. A malicious plugin could bypass these restrictions. True sandboxing (VM, worker threads) is a future consideration.
On startup, KinBot scans the plugins/ directory for folders containing a valid plugin.json. Plugins are loaded in alphabetical order.
Server Start
→ Scan plugins/
→ Validate each plugin.json
→ Register discovered plugins (not yet activated)
→ Activate globally-enabled plugins
→ For each Kin, activate Kin-specific plugins
| Method | How | Use Case |
|---|---|---|
| Manual | Copy folder to plugins/ |
Development, local plugins |
| Git clone | git clone <repo> plugins/<name> |
Shared plugins |
| npm | cd plugins && npm init -y && npm install kinbot-plugin-<name> |
Published plugins (future) |
| UI upload | Upload ZIP via Plugin Manager | Non-technical users (future) |
After placing files, restart KinBot or use the Reload Plugins button in the UI (triggers re-scan without full restart).
Plugins have two levels of enablement:
- Global — Plugin is active at the platform level. Its providers/channels/hooks are registered.
- Per-Kin — Plugin's tools are available to specific Kins. Configured in each Kin's settings.
Plugin installed but disabled → Nothing loaded
Plugin globally enabled → Hooks, providers, channels active. Tools available for Kin opt-in.
Plugin enabled for Kin X → Kin X gets the plugin's tools in its tool set.
- Config changes — Applied immediately (no restart). Plugin's
deactivate()→ re-init with new config →activate(). - Code changes — Require clicking Reload Plugins or restarting KinBot. Bun re-imports the module.
- Manifest changes — Require reload.
- Plugin is deactivated (
deactivate()called) - All hooks/tools/providers/channels are unregistered
- User optionally deletes plugin storage data
- Plugin folder is deleted (manual or via UI)
Just a folder in plugins/. This is the primary and recommended approach.
Convention: packages named kinbot-plugin-* on npm. Install with:
# From KinBot root
bun add kinbot-plugin-weather --cwd plugins/weatherThe UI could automate this in the future.
A community registry (like Home Assistant's HACS or Obsidian's plugin list):
- JSON index file hosted on GitHub
- Plugins listed with name, description, repo URL, version
- UI can browse, install, update from the registry
- Not in v1 — build the local plugin system first
- Plugins declare their compatible KinBot version range in
kinbotfield - KinBot checks compatibility on load and warns on mismatch
- Plugin authors should follow semver for their own versions
Plugins declare required permissions in plugin.json:
| Permission | Grants |
|---|---|
http:<host_pattern> |
HTTP access to matching hosts (supports * wildcard) |
memory:read |
Read Kin memories |
memory:write |
Write/update Kin memories |
notify |
Send notifications to users |
storage |
Use plugin key-value storage (always granted) |
hooks:<hook_name> |
Register a specific hook |
Example:
{
"permissions": [
"http:api.openweathermap.org",
"http:*.twilio.com",
"memory:read",
"notify"
]
}When a plugin is first enabled:
- UI shows a confirmation dialog listing all declared permissions
- Admin must approve
- Approval is stored in the database
- If plugin updates add new permissions, re-approval is required
| Concern | v1 Approach | Future |
|---|---|---|
| Can crash KinBot? | Yes (same process). Use try/catch wrappers on all plugin calls. | Worker threads |
| Access other Kins' data? | No — tool context is scoped to the executing Kin. Memory API requires kinId. |
Same |
| Access host filesystem? | Prevented by convention (no fs import). |
VM sandbox |
| Access other plugins? | Prevented by module boundaries | Same |
| Network access | Gated by http: permissions |
Same |
Error isolation: All plugin function calls are wrapped in try/catch. A failing plugin logs the error and returns a tool error to the LLM — it does not crash the server.
New route: Settings → Plugins
| Feature | Description |
|---|---|
| Plugin list | Shows all discovered plugins with name, description, version, status |
| Enable/Disable toggle | Global enable/disable |
| Configure button | Opens auto-generated settings form from config schema |
| Reload button | Re-scans plugins/ and reloads changed plugins |
| Permission badge | Shows granted permissions |
| Error indicator | Red badge if plugin failed to load |
In the Kin settings page, add a Plugins tab:
- List of globally-enabled plugins that provide tools
- Toggle each on/off for this Kin
- Same pattern as the existing
enabledOptInToolsmechanism
The config schema in plugin.json drives a dynamic form:
string+secret: true→ password inputselect→ dropdownboolean→ togglenumber→ number input with min/max- Validation from
required,pattern,min,max
Settings are stored in the plugin_configs table (new), keyed by plugin name.
plugins/weather/plugin.json
{
"name": "weather",
"version": "1.0.0",
"description": "Get current weather and forecasts",
"author": "KinBot Community",
"kinbot": ">=0.10.0",
"main": "index.ts",
"permissions": [
"http:api.openweathermap.org"
],
"config": {
"apiKey": {
"type": "string",
"label": "OpenWeatherMap API Key",
"required": true,
"secret": true
},
"units": {
"type": "select",
"label": "Units",
"options": ["metric", "imperial"],
"default": "metric"
}
}
}plugins/weather/index.ts
import type { PluginContext, PluginExports } from 'kinbot/plugin'
import { tool } from 'ai'
import { z } from 'zod'
export default function(ctx: PluginContext): PluginExports {
const { apiKey, units } = ctx.config
return {
tools: {
get_weather: {
availability: ['main', 'sub-kin'],
create: () =>
tool({
description: 'Get current weather for a location',
inputSchema: z.object({
location: z.string().describe('City name or "lat,lon"'),
}),
execute: async ({ location }) => {
const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(location)}&units=${units}&appid=${apiKey}`
const res = await ctx.http.fetch(url)
const data = await res.json()
return {
location: data.name,
temperature: data.main.temp,
feels_like: data.main.feels_like,
humidity: data.main.humidity,
description: data.weather[0].description,
wind_speed: data.wind.speed,
}
},
}),
},
get_forecast: {
availability: ['main'],
create: () =>
tool({
description: 'Get 5-day weather forecast',
inputSchema: z.object({
location: z.string().describe('City name'),
days: z.number().min(1).max(5).optional().describe('Number of days (default: 3)'),
}),
execute: async ({ location, days = 3 }) => {
const url = `https://api.openweathermap.org/data/2.5/forecast?q=${encodeURIComponent(location)}&units=${units}&cnt=${days * 8}&appid=${apiKey}`
const res = await ctx.http.fetch(url)
const data = await res.json()
return {
location: data.city.name,
forecasts: data.list.filter((_: any, i: number) => i % 8 === 0).map((entry: any) => ({
date: entry.dt_txt,
temp: entry.main.temp,
description: entry.weather[0].description,
})),
}
},
}),
},
},
}
}plugins/twilio-sms/plugin.json
{
"name": "twilio-sms",
"version": "1.0.0",
"description": "Send and receive SMS via Twilio",
"author": "KinBot Community",
"kinbot": ">=0.10.0",
"main": "index.ts",
"permissions": [
"http:api.twilio.com",
"notify"
],
"config": {
"accountSid": {
"type": "string",
"label": "Twilio Account SID",
"required": true
},
"authToken": {
"type": "string",
"label": "Twilio Auth Token",
"required": true,
"secret": true
},
"fromNumber": {
"type": "string",
"label": "Twilio Phone Number",
"description": "Your Twilio number in E.164 format (e.g. +15551234567)",
"required": true
}
}
}plugins/twilio-sms/index.ts
import type { PluginContext, PluginExports } from 'kinbot/plugin'
import { tool } from 'ai'
import { z } from 'zod'
export default function(ctx: PluginContext): PluginExports {
const { accountSid, authToken, fromNumber } = ctx.config
const authHeader = 'Basic ' + btoa(`${accountSid}:${authToken}`)
return {
tools: {
send_sms: {
availability: ['main'],
create: () =>
tool({
description: 'Send an SMS message via Twilio',
inputSchema: z.object({
to: z.string().describe('Recipient phone number in E.164 format (e.g. +33612345678)'),
body: z.string().max(1600).describe('Message text'),
}),
execute: async ({ to, body }) => {
const url = `https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Messages.json`
const res = await ctx.http.fetch(url, {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
To: to,
From: fromNumber,
Body: body,
}),
})
const data = await res.json()
if (data.error_code) {
return { error: `Twilio error ${data.error_code}: ${data.error_message}` }
}
return {
success: true,
sid: data.sid,
status: data.status,
to: data.to,
}
},
}),
},
},
async activate() {
ctx.log.info('Twilio SMS plugin activated')
},
}
}plugins/audit-log/plugin.json
{
"name": "audit-log",
"version": "1.0.0",
"description": "Log all tool calls to a file for auditing",
"author": "KinBot Community",
"kinbot": ">=0.10.0",
"main": "index.ts",
"permissions": [
"hooks:afterToolCall",
"hooks:beforeChat",
"storage"
],
"config": {
"logLevel": {
"type": "select",
"label": "Log Level",
"options": ["all", "tools-only", "errors-only"],
"default": "all"
}
}
}plugins/audit-log/index.ts
import type { PluginContext, PluginExports } from 'kinbot/plugin'
import type { HookContext } from 'kinbot/plugin'
export default function(ctx: PluginContext): PluginExports {
const { logLevel } = ctx.config
return {
hooks: {
afterToolCall: async (hookCtx: HookContext) => {
const entry = {
timestamp: new Date().toISOString(),
kinId: hookCtx.kinId,
tool: hookCtx.toolName,
args: hookCtx.toolArgs,
hasError: !!hookCtx.toolResult?.error,
}
if (logLevel === 'errors-only' && !entry.hasError) return
// Append to plugin storage
const logKey = `log:${new Date().toISOString().split('T')[0]}`
const existing = await ctx.storage.get<any[]>(logKey) ?? []
existing.push(entry)
await ctx.storage.set(logKey, existing)
ctx.log.debug({ tool: entry.tool, kinId: entry.kinId }, 'Tool call logged')
},
beforeChat: async (hookCtx: HookContext) => {
if (logLevel !== 'all') return
const entry = {
timestamp: new Date().toISOString(),
kinId: hookCtx.kinId,
event: 'chat_started',
}
const logKey = `log:${new Date().toISOString().split('T')[0]}`
const existing = await ctx.storage.get<any[]>(logKey) ?? []
existing.push(entry)
await ctx.storage.set(logKey, existing)
},
},
async activate() {
ctx.log.info('Audit log plugin activated')
},
async deactivate() {
ctx.log.info('Audit log plugin deactivated')
},
}
}Built-in tools (src/server/tools/) remain unchanged. The plugin system is additive:
registerAllTools()continues to register core tools- Plugin tools are registered after core tools
- If a plugin tool name conflicts with a core tool, the core tool wins (plugin load fails with a warning)
The current register_tool / run_custom_tool system (shell script-based) remains as-is. Plugins are a higher-level alternative for users who want TypeScript tools with config UI.
The HookRegistry already exists and is used internally. Plugins register hooks through the same registry — no changes needed.
-
PluginManagerclass: scanplugins/, validate manifests, load entry points -
PluginContextimplementation: config, logger, storage, http - Tool registration from plugins into existing
toolRegistry - Hook registration from plugins into existing
hookRegistry - Database:
plugin_configstable,plugin_statestable (enabled/disabled) - Error wrapping: try/catch all plugin function calls
- Settings → Plugins page (list, enable/disable, configure)
- Auto-generated config forms from manifest schema
- Per-Kin plugin tool selection (extend existing Kin settings)
- Reload Plugins button
- Provider registration from plugins
- Channel registration from plugins
- UI for managing plugin-provided providers/channels
- Community registry index (JSON index on GitHub, browsable from UI)
- Plugin template / scaffolding CLI (
bunx create-kinbot-plugin)
// kinbot/plugin — public SDK types
export interface PluginManifest {
name: string
version: string
description: string
author?: string
homepage?: string
license?: string
kinbot?: string
main: string
icon?: string
permissions?: string[]
config?: Record<string, PluginConfigField>
}
export interface PluginConfigField {
type: 'string' | 'number' | 'boolean' | 'select' | 'text'
label: string
description?: string
required?: boolean
default?: any
secret?: boolean
// type-specific
options?: string[] // select
min?: number // number
max?: number // number
step?: number // number
placeholder?: string // string, text
pattern?: string // string
rows?: number // text
}
export interface PluginContext {
config: Record<string, any>
log: PluginLogger
storage: PluginStorage
http: PluginHTTPClient
memory: PluginMemoryAPI
notify: PluginNotifyAPI
manifest: PluginManifest
}
export interface PluginExports {
tools?: Record<string, ToolRegistration>
providers?: Record<string, ProviderDefinition>
channels?: Record<string, ChannelAdapter>
hooks?: Partial<Record<HookName, HookHandler>>
activate?(): Promise<void>
deactivate?(): Promise<void>
}
export interface PluginStorage {
get<T = unknown>(key: string): Promise<T | null>
set<T = unknown>(key: string, value: T): Promise<void>
delete(key: string): Promise<void>
list(prefix?: string): Promise<string[]>
clear(): Promise<void>
}
export interface PluginHTTPClient {
fetch(url: string, init?: RequestInit): Promise<Response>
}
export interface PluginLogger {
debug(msg: string): void
debug(obj: Record<string, any>, msg: string): void
info(msg: string): void
info(obj: Record<string, any>, msg: string): void
warn(msg: string): void
warn(obj: Record<string, any>, msg: string): void
error(msg: string): void
error(obj: Record<string, any>, msg: string): void
}The store/ directory contains community plugins available for one-click install:
| Plugin | Icon | Description |
|---|---|---|
rss-reader |
📰 | Fetch and summarize RSS/Atom feeds |
pomodoro |
🍅 | Pomodoro timer for focused work sessions |
system-monitor |
📊 | Monitor CPU, memory, disk, uptime, and processes |