Plugin System
AIRI’s plugin system allows you to extend AIRI’s capabilities by integrating new services, tools, and functionalities. This guide covers everything you need to know about creating, testing, and publishing AIRI plugins.
The plugin SDK (@proj-airi/plugin-sdk) is currently a work in progress. APIs may change without warning. Check the GitHub repository for the latest updates.
Plugin System Overview
The AIRI plugin system is built on a client-server architecture using WebSocket communication:
Key Concepts
A self-contained module that extends AIRI’s functionality. Plugins connect to the server runtime via WebSocket and can:
Receive configuration from UI
Send events to other modules
Expose tools and capabilities
Integrate external services
The central communication hub (@proj-airi/server-runtime) that:
Routes messages between plugins and UI
Manages plugin lifecycle
Handles authentication
Maintains plugin registry
The SDK (@proj-airi/plugin-sdk) provides:
WebSocket client abstraction
Event handling utilities
Type-safe communication
State machine for lifecycle management
The protocol (@proj-airi/plugin-protocol) defines:
Message formats
Event types
Metadata structure
Routing rules
Plugin Architecture
Communication Flow
Plugin Lifecycle
Initialize : Plugin creates SDK client instance
Connect : WebSocket connection established
Authenticate : Plugin sends auth token (if required)
Announce : Plugin announces its presence and capabilities
Configure : Plugin receives configuration from UI
Active : Plugin sends/receives events
Disconnect : WebSocket connection closed
Creating a Plugin
Project Setup
Create Plugin Directory
Create a new directory in the plugins/ folder: mkdir plugins/airi-plugin-my-plugin
cd plugins/airi-plugin-my-plugin
Initialize Package
Create a package.json: {
"name" : "@proj-airi/airi-plugin-my-plugin" ,
"version" : "0.8.5-beta.4" ,
"type" : "module" ,
"private" : true ,
"main" : "./dist/index.mjs" ,
"types" : "./dist/index.d.mts" ,
"scripts" : {
"dev" : "tsx ./src/index.ts" ,
"build" : "tsdown" ,
"typecheck" : "tsc --noEmit"
},
"dependencies" : {
"@proj-airi/plugin-sdk" : "workspace:^" ,
"@proj-airi/server-sdk" : "workspace:^"
},
"devDependencies" : {
"tsdown" : "catalog:" ,
"tsx" : "^4.21.0" ,
"typescript" : "~5.9.3"
}
}
Create TypeScript Config
Create tsconfig.json: {
"extends" : "../../tsconfig.json" ,
"compilerOptions" : {
"outDir" : "./dist" ,
"rootDir" : "./src"
},
"include" : [ "src/**/*" ]
}
Create Build Config
Create tsdown.config.ts: import { defineConfig } from 'tsdown'
export default defineConfig ({
entry: [ 'src/index.ts' ] ,
format: [ 'esm' ] ,
dts: true ,
clean: true ,
})
Basic Plugin Implementation
Create src/index.ts:
import { Client } from '@proj-airi/server-sdk'
// Define plugin metadata
const PLUGIN_ID = 'my-plugin'
const PLUGIN_VERSION = '0.1.0'
// Create client
const client = new Client ({
name: PLUGIN_ID ,
url: process . env . SERVER_URL || 'ws://localhost:3000/ws' ,
auth: process . env . AUTH_TOKEN ,
})
// Handle configuration
client . on ( 'module:configure' , ( event ) => {
const config = event . data . config
console . log ( 'Received configuration:' , config )
// Apply configuration
applyConfig ( config )
})
// Handle custom events
client . on ( 'custom:trigger' , async ( event ) => {
console . log ( 'Custom event received:' , event . data )
// Do something
const result = await performAction ( event . data )
// Send response
client . send ({
type: 'custom:response' ,
data: { result },
metadata: {
event: {
id: generateId (),
parentId: event . metadata ?. event . id ,
},
source: {
kind: 'plugin' ,
plugin: {
id: PLUGIN_ID ,
version: PLUGIN_VERSION ,
},
id: client . instanceId ,
},
},
})
})
// Connect
await client . connect ()
// Announce plugin
await client . announce ({
name: PLUGIN_ID ,
identity: {
kind: 'plugin' ,
plugin: {
id: PLUGIN_ID ,
version: PLUGIN_VERSION ,
},
id: client . instanceId ,
},
})
console . log ( `Plugin ${ PLUGIN_ID } started successfully` )
// Helper functions
function applyConfig ( config : Record < string , unknown >) {
// Apply configuration to your plugin
}
async function performAction ( data : unknown ) {
// Your plugin logic here
return { success: true }
}
function generateId () {
return Math . random (). toString ( 36 ). substring ( 7 )
}
Advanced Plugin Features
State Management
Event Routing
Error Handling
Heartbeat
Use XState for complex plugin state: import { createMachine , interpret } from 'xstate'
const pluginMachine = createMachine ({
id: 'plugin' ,
initial: 'idle' ,
states: {
idle: {
on: { START: 'running' }
},
running: {
on: { STOP: 'idle' , ERROR: 'error' }
},
error: {
on: { RETRY: 'running' }
},
},
})
const service = interpret ( pluginMachine )
service . start ()
// React to state changes
service . onTransition (( state ) => {
console . log ( 'State:' , state . value )
})
Control where your events are sent: // Send to specific modules
client . send ({
type: 'custom:notification' ,
data: { message: 'Hello!' },
route: {
destinations: [
{ module: 'stage-ui' },
{ module: 'another-plugin' , index: 0 },
],
},
})
// Broadcast to all
client . send ({
type: 'custom:broadcast' ,
data: { announcement: 'Important!' },
// No route = broadcast to all authenticated peers
})
Handle errors gracefully: client . on ( 'error' , ( error ) => {
console . error ( 'Client error:' , error )
// Attempt reconnection
setTimeout (() => {
client . connect ()
}, 5000 )
})
client . on ( 'close' , ( event ) => {
console . log ( 'Connection closed:' , event )
// Cleanup and reconnect logic
})
Maintain connection with heartbeat: // The SDK handles heartbeat automatically
// But you can customize it:
const client = new Client ({
name: PLUGIN_ID ,
heartbeat: {
interval: 30000 , // 30 seconds
timeout: 60000 , // 60 seconds
},
})
Plugin Examples
Example 1: Web Extension Plugin
The airi-plugin-web-extension shows how to create a browser extension plugin:
// Background script
import { Client } from '@proj-airi/server-sdk'
const client = new Client ({
name: 'web-extension' ,
url: 'ws://localhost:3000/ws' ,
})
// Listen for page content
chrome . tabs . onUpdated . addListener (( tabId , changeInfo , tab ) => {
if ( changeInfo . status === 'complete' ) {
// Send page info to AIRI
client . send ({
type: 'extension:page-loaded' ,
data: {
url: tab . url ,
title: tab . title ,
},
})
}
})
// Receive commands from AIRI
client . on ( 'extension:open-tab' , ( event ) => {
chrome . tabs . create ({ url: event . data . url })
})
Example 2: Claude Code Integration
The airi-plugin-claude-code demonstrates CLI tool integration:
import { spawn } from 'child_process'
import { Client } from '@proj-airi/server-sdk'
const client = new Client ({
name: 'claude-code' ,
url: process . env . SERVER_URL || 'ws://localhost:3000/ws' ,
})
// Handle code execution requests
client . on ( 'claude:execute' , async ( event ) => {
const { command , args } = event . data
const process = spawn ( command , args , {
cwd: event . data . cwd || process . cwd (),
})
// Stream output
process . stdout . on ( 'data' , ( data ) => {
client . send ({
type: 'claude:output' ,
data: {
stream: 'stdout' ,
content: data . toString (),
},
})
})
process . stderr . on ( 'data' , ( data ) => {
client . send ({
type: 'claude:output' ,
data: {
stream: 'stderr' ,
content: data . toString (),
},
})
})
process . on ( 'close' , ( code ) => {
client . send ({
type: 'claude:complete' ,
data: { exitCode: code },
})
})
})
Example 3: Home Assistant Integration
The airi-plugin-homeassistant is currently WIP but will demonstrate:
Connecting to Home Assistant API
Controlling smart home devices
Receiving sensor data
Automating routines
Check the plugins/ directory for complete, working examples of each plugin type.
Plugin SDK API
Client Class
class Client {
constructor ( options : ClientOptions )
// Connection
connect () : Promise < void >
disconnect () : void
// Lifecycle
announce ( data : AnnounceData ) : Promise < void >
// Events
send ( event : WebSocketEvent ) : void
on ( type : string , handler : ( event : WebSocketEvent ) => void ) : void
off ( type : string , handler ?: ( event : WebSocketEvent ) => void ) : void
// Properties
readonly instanceId : string
readonly connected : boolean
}
Client Options
interface ClientOptions {
name : string // Plugin name
url : string // WebSocket URL
auth ?: string // Authentication token
index ?: number // Plugin index (for multiple instances)
heartbeat ?: {
interval ?: number // Heartbeat interval (ms)
timeout ?: number // Heartbeat timeout (ms)
}
}
Event Structure
interface WebSocketEvent < T = Record < string , unknown >> {
type : string // Event type (e.g., 'custom:action')
data : T // Event payload
metadata ?: {
event : {
id : string // Unique event ID
parentId ?: string // Parent event ID (for request/response)
}
source : MetadataEventSource // Source information
}
route ?: {
destinations ?: Destination [] // Target modules
bypass ?: boolean // Bypass routing (devtools only)
}
}
Standard Event Types
Event Type Direction Purpose module:authenticatePlugin → Server Authenticate with token module:authenticatedServer → Plugin Authentication successful module:announcePlugin → Server Announce plugin presence module:configureServer → Plugin Receive configuration registry:modules:syncServer → Plugin Module registry update ui:configureUI → Server → Plugin UI configuration update transport:connection:heartbeatBidirectional Keep connection alive
Testing Plugins
Unit Testing
import { describe , it , expect , vi } from 'vitest'
import { Client } from '@proj-airi/server-sdk'
describe ( 'MyPlugin' , () => {
it ( 'should handle configuration' , async () => {
const client = new Client ({
name: 'test-plugin' ,
url: 'ws://localhost:3000/ws' ,
})
// Mock event handler
const handler = vi . fn ()
client . on ( 'module:configure' , handler )
// Simulate configuration event
client . emit ( 'module:configure' , {
type: 'module:configure' ,
data: { config: { enabled: true } },
})
expect ( handler ). toHaveBeenCalledWith (
expect . objectContaining ({
data: { config: { enabled: true } },
})
)
})
})
Integration Testing
# Start server runtime
pnpm -F @proj-airi/server-runtime dev
# In another terminal, start your plugin
pnpm -F @proj-airi/airi-plugin-my-plugin dev
# In another terminal, start Stage UI
pnpm dev:tamagotchi
Test the full flow:
Plugin connects and announces
UI sees plugin in registry
UI sends configuration
Plugin receives and applies configuration
Plugin sends events
UI receives and displays events
Best Practices
Error Handling Always handle connection errors and implement reconnection logic. Don’t let your plugin crash on network issues.
Type Safety Use TypeScript and define strict types for your event data. This prevents runtime errors and improves DX.
Event Naming Use consistent naming: <plugin-name>:<action> (e.g., weather:update, music:play).
Logging Use structured logging with context. This helps debugging in production.
Configuration Make your plugin configurable. Don’t hardcode values that users might want to change.
Documentation Document your plugin’s events, configuration options, and requirements in a README.
Code Style
Event Handlers
Error Messages
Configuration
// ✅ Good: Specific, focused handlers
client . on ( 'weather:update' , handleWeatherUpdate )
client . on ( 'weather:forecast' , handleForecast )
// ❌ Bad: Generic catch-all handler
client . on ( '*' , handleAllEvents )
// ✅ Good: Descriptive error messages
throw new Error ( 'Failed to fetch weather data: API key invalid' )
// ❌ Bad: Vague error messages
throw new Error ( 'Error' )
// ✅ Good: Validated configuration with defaults
const config = validateConfig ( event . data . config , {
apiKey: process . env . API_KEY || '' ,
refreshInterval: 60000 ,
})
// ❌ Bad: Direct usage without validation
const apiKey = event . data . config . apiKey
Publishing Plugins
Internal Plugins
Internal plugins (in plugins/ directory):
Follow monorepo conventions
Use workspace:^ for internal dependencies
Add to workspace in root package.json
Document in main README
External Plugins
For external/community plugins:
Create Standalone Repository
Create a new repository following the plugin template structure.
Publish to npm
Use scoped package name: @your-org/airi-plugin-name
Document Installation
Provide clear installation and usage instructions: npm install @your-org/airi-plugin-name
Submit to Plugin Registry
(Future feature) Submit your plugin to the AIRI plugin registry.
Plugin Distribution
// package.json for distribution
{
"name" : "@your-org/airi-plugin-name" ,
"version" : "1.0.0" ,
"type" : "module" ,
"main" : "./dist/index.mjs" ,
"types" : "./dist/index.d.mts" ,
"bin" : {
"airi-plugin-name" : "./dist/cli.mjs"
},
"files" : [
"dist" ,
"README.md" ,
"LICENSE"
],
"peerDependencies" : {
"@proj-airi/plugin-sdk" : "^0.8.0" ,
"@proj-airi/server-sdk" : "^0.8.0"
}
}
Troubleshooting
Check:
Server runtime is running (pnpm -F @proj-airi/server-runtime dev)
WebSocket URL is correct
No firewall blocking WebSocket connections
Authentication token matches (if auth enabled)
Debug: # Enable debug logging
LOG_LEVEL = debug pnpm -F @proj-airi/server-runtime dev
Check:
Plugin announced itself with client.announce()
Event type matches exactly (case-sensitive)
Event routing destinations are correct
Plugin is authenticated (if auth enabled)
Debug: client . on ( '*' , ( event ) => {
console . log ( 'Received event:' , event . type , event . data )
})
Check:
Heartbeat is enabled and working
Network is stable
Server runtime hasn’t crashed
Fix: client . on ( 'close' , async () => {
console . log ( 'Connection closed, reconnecting...' )
await new Promise ( resolve => setTimeout ( resolve , 5000 ))
await client . connect ()
})
Check:
Using latest @proj-airi/plugin-sdk version
TypeScript version is ~5.9.3
Event data matches expected types
Fix: pnpm install
pnpm typecheck
Resources
Plugin SDK Repo Browse the plugin SDK source code
Example Plugins Study working plugin implementations
Server Runtime Understand the server architecture
Join Discord Get help from the community
Next Steps
Build Your Plugin
Start with a simple plugin and gradually add features.
Share Your Work
Publish your plugin and share it with the community!