Skip to main content

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

  1. Initialize: Plugin creates SDK client instance
  2. Connect: WebSocket connection established
  3. Authenticate: Plugin sends auth token (if required)
  4. Announce: Plugin announces its presence and capabilities
  5. Configure: Plugin receives configuration from UI
  6. Active: Plugin sends/receives events
  7. Disconnect: WebSocket connection closed

Creating a Plugin

Project Setup

1

Create Plugin Directory

Create a new directory in the plugins/ folder:
mkdir plugins/airi-plugin-my-plugin
cd plugins/airi-plugin-my-plugin
2

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"
  }
}
3

Create TypeScript Config

Create tsconfig.json:
{
  "extends": "../../tsconfig.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"]
}
4

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

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)
})

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 TypeDirectionPurpose
module:authenticatePlugin → ServerAuthenticate with token
module:authenticatedServer → PluginAuthentication successful
module:announcePlugin → ServerAnnounce plugin presence
module:configureServer → PluginReceive configuration
registry:modules:syncServer → PluginModule registry update
ui:configureUI → Server → PluginUI configuration update
transport:connection:heartbeatBidirectionalKeep 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:
  1. Plugin connects and announces
  2. UI sees plugin in registry
  3. UI sends configuration
  4. Plugin receives and applies configuration
  5. Plugin sends events
  6. 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

// ✅ Good: Specific, focused handlers
client.on('weather:update', handleWeatherUpdate)
client.on('weather:forecast', handleForecast)

// ❌ Bad: Generic catch-all handler
client.on('*', handleAllEvents)

Publishing Plugins

Internal Plugins

Internal plugins (in plugins/ directory):
  1. Follow monorepo conventions
  2. Use workspace:^ for internal dependencies
  3. Add to workspace in root package.json
  4. Document in main README

External Plugins

For external/community plugins:
1

Create Standalone Repository

Create a new repository following the plugin template structure.
2

Publish to npm

npm publish
Use scoped package name: @your-org/airi-plugin-name
3

Document Installation

Provide clear installation and usage instructions:
npm install @your-org/airi-plugin-name
4

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

1

Review Architecture

Understand the architecture overview first.
2

Set Up Development

Follow the contributing guide to set up your environment.
3

Build Your Plugin

Start with a simple plugin and gradually add features.
4

Share Your Work

Publish your plugin and share it with the community!

Build docs developers (and LLMs) love