← Back to Blog
Plugins, Adapters and Contributing to OpenACP
15 min read plugins contributing open-source

Building on OpenACP: Plugins, Adapters & How to Contribute

OpenACP is open-source under the MIT license, and its architecture is designed for extensibility. Whether you want to add support for a new messaging platform, build a plugin that adds custom functionality, or contribute bug fixes to the core project, this guide gives you everything you need to get started. We will walk through the internal architecture, the plugin API, the adapter abstraction layer, the test suite, and the contribution process.

Understanding the Architecture

Before you can extend OpenACP effectively, you need to understand how the system is structured. OpenACP follows a layered architecture with three core abstractions:

OpenACPCore

OpenACPCore is the top-level orchestrator. It owns the configuration, manages platform adapters, handles the REST API server, and coordinates the lifecycle of all sessions. There is exactly one OpenACPCore instance per running OpenACP process.

// Simplified OpenACPCore structure
class OpenACPCore {
  private config: ValidatedConfig;
  private adapters: Map<string, ChannelAdapter>;
  private sessions: Map<string, Session>;
  private apiServer: Hono;
  private pluginManager: PluginManager;
  private logger: pino.Logger;

  constructor(config: ValidatedConfig) {
    this.config = config;
    this.adapters = new Map();
    this.sessions = new Map();
    this.logger = pino({ level: config.logLevel || 'info' });
  }

  async start() {
    // 1. Validate configuration with Zod
    this.config = ConfigSchema.parse(this.config);

    // 2. Load plugins from ~/.openacp/plugins/
    await this.pluginManager.loadPlugins();

    // 3. Initialize platform adapters
    if (this.config.telegram) {
      this.adapters.set('telegram', new TelegramAdapter(this.config.telegram, this));
    }
    if (this.config.discord) {
      this.adapters.set('discord', new DiscordAdapter(this.config.discord, this));
    }
    if (this.config.slack) {
      this.adapters.set('slack', new SlackAdapter(this.config.slack, this));
    }

    // 4. Start REST API server (Hono)
    this.apiServer = createApiServer(this);
    this.apiServer.listen(this.config.api?.port || 21420);

    // 5. Connect all adapters
    for (const adapter of this.adapters.values()) {
      await adapter.connect();
    }

    this.logger.info('OpenACP started successfully');
  }

  async createSession(userId: string, platform: string, agent: string): Promise<Session> {
    // Check session limits
    if (this.sessions.size >= this.config.maxConcurrentSessions) {
      throw new Error('Max concurrent sessions reached');
    }

    const session = new Session({
      id: generateSessionId(),
      userId,
      platform,
      agent,
      cwd: this.config.cwd || process.cwd(),
      autoApprove: this.config.autoApprove || false,
      timeout: this.config.sessionTimeout || 60,
      core: this
    });

    await session.start();
    this.sessions.set(session.id, session);
    return session;
  }
}

Session

A Session represents a single conversation between a user and an AI agent. Each session manages its own AgentInstance subprocess, conversation history, permission gate, and timeout. Sessions are created by OpenACPCore and owned by a specific platform adapter.

class Session {
  readonly id: string;
  readonly userId: string;
  readonly platform: string;
  private agent: AgentInstance;
  private history: ConversationMessage[];
  private permissionGate: PermissionGate;
  private timeoutTimer: NodeJS.Timeout;
  private adapter: ChannelAdapter;

  async start() {
    // Spawn the agent subprocess
    this.agent = new AgentInstance({
      type: this.agentType,
      cwd: this.cwd,
      onOutput: (chunk) => this.handleAgentOutput(chunk),
      onToolUse: (tool, args) => this.handleToolUse(tool, args),
      onComplete: () => this.handleAgentComplete(),
    });

    await this.agent.spawn();

    // Start session timeout
    this.timeoutTimer = setTimeout(
      () => this.terminate('timeout'),
      this.timeout * 60 * 1000
    );
  }

  async sendMessage(content: string) {
    this.history.push({ role: 'user', content });
    await this.agent.send(content);
  }

  private async handleToolUse(tool: string, args: Record<string, unknown>) {
    if (this.autoApprove) {
      return { approved: true };
    }

    // Send permission request to user via platform adapter
    const decision = await this.permissionGate.request({
      tool,
      args,
      timeout: 10 * 60 * 1000  // 10 minutes
    });

    return decision;
  }

  async terminate(reason: string = 'user') {
    clearTimeout(this.timeoutTimer);
    await this.agent.kill();
    this.emit('terminated', { reason });
  }
}

AgentInstance

AgentInstance wraps the AI agent subprocess. It handles spawning the agent binary (claude, gemini, codex, etc.), communicating with it via the Agent Client Protocol, and managing the process lifecycle.

class AgentInstance {
  private process: ChildProcess;
  private acpStream: ACPStream;
  readonly type: string;

  async spawn() {
    const binary = this.resolveBinary(this.type);

    this.process = spawn(binary, ['--acp'], {
      cwd: this.cwd,
      stdio: ['pipe', 'pipe', 'pipe'],
      env: {
        ...process.env,
        ACP_MODE: 'subprocess'
      }
    });

    // Set up ACP protocol communication
    this.acpStream = new ACPStream(this.process.stdin, this.process.stdout);

    this.acpStream.on('text', (chunk) => this.onOutput(chunk));
    this.acpStream.on('tool_use', (tool, args) => this.onToolUse(tool, args));
    this.acpStream.on('complete', () => this.onComplete());

    this.process.on('exit', (code) => {
      this.logger.info(`Agent process exited with code ${code}`);
    });
  }

  async send(message: string) {
    await this.acpStream.write({
      type: 'message',
      role: 'user',
      content: message
    });
  }

  async kill() {
    this.process.kill('SIGTERM');
    // Grace period
    await new Promise(resolve => setTimeout(resolve, 5000));
    if (!this.process.killed) {
      this.process.kill('SIGKILL');
    }
  }
}

The relationship between these three classes is hierarchical: OpenACPCore creates Session instances, and each Session creates and manages one AgentInstance. This clean separation means you can modify how agents are spawned without touching session management, or add new platform adapters without touching the agent layer.

The ChannelAdapter Base Class

Platform adapters (Telegram, Discord, Slack) all extend the abstract ChannelAdapter base class. This is the key abstraction that makes OpenACP platform-agnostic. If you want to add support for a new messaging platform, you implement a new ChannelAdapter.

// The abstract base class that all adapters implement
abstract class ChannelAdapter {
  protected core: OpenACPCore;
  protected config: AdapterConfig;
  protected logger: pino.Logger;

  constructor(config: AdapterConfig, core: OpenACPCore) {
    this.config = config;
    this.core = core;
    this.logger = core.logger.child({ component: this.name });
  }

  // Required: unique adapter name
  abstract get name(): string;

  // Required: connect to the platform
  abstract connect(): Promise<void>;

  // Required: disconnect from the platform
  abstract disconnect(): Promise<void>;

  // Required: send a text message to the user
  abstract sendMessage(channelId: string, content: string): Promise<void>;

  // Required: send a permission request with approve/deny buttons
  abstract sendPermissionRequest(
    channelId: string,
    tool: string,
    args: Record<string, unknown>
  ): Promise<PermissionResponse>;

  // Required: stream text content to the user
  abstract streamMessage(
    channelId: string,
    stream: AsyncIterable<string>
  ): Promise<void>;

  // Optional: send a file or document
  async sendFile(channelId: string, path: string, caption?: string): Promise<void> {
    // Default: send as text with file content
    const content = await fs.readFile(path, 'utf-8');
    await this.sendMessage(channelId, `File: ${path}\n\`\`\`\n${content}\n\`\`\``);
  }

  // Optional: send a voice message
  async sendVoice(channelId: string, audioBuffer: Buffer): Promise<void> {
    this.logger.warn('Voice messages not supported by this adapter');
  }

  // Optional: handle voice input
  async handleVoiceInput(audioBuffer: Buffer, format: string): Promise<string> {
    return this.core.transcribeAudio(audioBuffer, format);
  }

  // Called by the core when a message is received from the platform
  protected async onMessageReceived(userId: string, channelId: string, content: string) {
    // Check if user is allowed
    if (!this.isUserAllowed(userId)) {
      this.logger.debug(`Ignoring message from unauthorized user: ${userId}`);
      return;
    }

    // Find or create session
    let session = this.core.findSession(userId, channelId);
    if (!session) {
      session = await this.core.createSession(userId, this.name, this.config.defaultAgent);
    }

    // Forward message to agent
    await session.sendMessage(content);
  }

  private isUserAllowed(userId: string): boolean {
    if (!this.config.allowedUserIds || this.config.allowedUserIds.length === 0) {
      return false;
    }
    return this.config.allowedUserIds.includes(userId);
  }
}

Building a Custom Adapter: Step by Step

Let us walk through building a custom adapter for a hypothetical platform. We will use Matrix (the decentralized messaging protocol) as our example, since it is a popular open-source messaging platform that OpenACP does not yet support natively.

Step 1: Set Up the Project

# Create the adapter package
mkdir openacp-adapter-matrix
cd openacp-adapter-matrix
npm init -y
npm install matrix-js-sdk typescript @types/node

# Create the source file
mkdir src
touch src/index.ts

Step 2: Implement the Adapter

// src/index.ts
import { ChannelAdapter, AdapterConfig, PermissionResponse } from '@openacp/core';
import * as matrix from 'matrix-js-sdk';

interface MatrixAdapterConfig extends AdapterConfig {
  homeserverUrl: string;
  accessToken: string;
  userId: string;
  allowedUserIds: string[];
}

export class MatrixAdapter extends ChannelAdapter {
  private client: matrix.MatrixClient;
  private config: MatrixAdapterConfig;

  get name(): string {
    return 'matrix';
  }

  async connect(): Promise<void> {
    this.client = matrix.createClient({
      baseUrl: this.config.homeserverUrl,
      accessToken: this.config.accessToken,
      userId: this.config.userId,
    });

    // Listen for incoming messages
    this.client.on('Room.timeline', async (event, room) => {
      if (event.getType() !== 'm.room.message') return;
      if (event.getSender() === this.config.userId) return; // Ignore own messages

      const content = event.getContent();
      if (content.msgtype !== 'm.text') return;

      await this.onMessageReceived(
        event.getSender(),
        room.roomId,
        content.body
      );
    });

    await this.client.startClient({ initialSyncLimit: 0 });
    this.logger.info('Matrix adapter connected');
  }

  async disconnect(): Promise<void> {
    this.client.stopClient();
    this.logger.info('Matrix adapter disconnected');
  }

  async sendMessage(roomId: string, content: string): Promise<void> {
    // Split long messages into chunks (Matrix has a message size limit)
    const chunks = this.splitMessage(content, 4000);

    for (const chunk of chunks) {
      await this.client.sendMessage(roomId, {
        msgtype: 'm.text',
        body: chunk,
        format: 'org.matrix.custom.html',
        formatted_body: this.markdownToHtml(chunk),
      });
    }
  }

  async sendPermissionRequest(
    roomId: string,
    tool: string,
    args: Record<string, unknown>
  ): Promise<PermissionResponse> {
    // Matrix doesn't have native buttons, so we use reactions
    const eventId = await this.client.sendMessage(roomId, {
      msgtype: 'm.text',
      body: `Permission Request: ${tool}\n\nArgs: ${JSON.stringify(args, null, 2)}\n\nReact with ✅ to approve or ❌ to deny`,
    });

    // Wait for reaction
    return new Promise((resolve) => {
      const handler = (event: any) => {
        if (event.getType() !== 'm.reaction') return;

        const relation = event.getContent()['m.relates_to'];
        if (relation?.event_id !== eventId) return;

        const reaction = relation?.key;
        this.client.removeListener('Room.timeline', handler);

        if (reaction === '✅') {
          resolve({ approved: true });
        } else {
          resolve({ approved: false, reason: 'User denied' });
        }
      };

      this.client.on('Room.timeline', handler);

      // 10-minute timeout
      setTimeout(() => {
        this.client.removeListener('Room.timeline', handler);
        resolve({ approved: false, reason: 'Timeout' });
      }, 10 * 60 * 1000);
    });
  }

  async streamMessage(
    roomId: string,
    stream: AsyncIterable<string>
  ): Promise<void> {
    let buffer = '';
    let lastEditId: string | null = null;
    let lastUpdateTime = 0;

    for await (const chunk of stream) {
      buffer += chunk;

      // Throttle updates to every 500ms
      const now = Date.now();
      if (now - lastUpdateTime < 500) continue;
      lastUpdateTime = now;

      if (lastEditId) {
        // Edit the existing message
        await this.client.sendMessage(roomId, {
          msgtype: 'm.text',
          body: buffer,
          'm.new_content': {
            msgtype: 'm.text',
            body: buffer,
          },
          'm.relates_to': {
            rel_type: 'm.replace',
            event_id: lastEditId,
          },
        });
      } else {
        // Send initial message
        const response = await this.client.sendMessage(roomId, {
          msgtype: 'm.text',
          body: buffer,
        });
        lastEditId = response.event_id;
      }
    }

    // Final update with complete content
    if (lastEditId) {
      await this.client.sendMessage(roomId, {
        msgtype: 'm.text',
        body: buffer,
        format: 'org.matrix.custom.html',
        formatted_body: this.markdownToHtml(buffer),
        'm.new_content': {
          msgtype: 'm.text',
          body: buffer,
          format: 'org.matrix.custom.html',
          formatted_body: this.markdownToHtml(buffer),
        },
        'm.relates_to': {
          rel_type: 'm.replace',
          event_id: lastEditId,
        },
      });
    }
  }

  private splitMessage(text: string, maxLength: number): string[] {
    if (text.length <= maxLength) return [text];
    const chunks: string[] = [];
    for (let i = 0; i < text.length; i += maxLength) {
      chunks.push(text.slice(i, i + maxLength));
    }
    return chunks;
  }

  private markdownToHtml(markdown: string): string {
    // Basic markdown to HTML conversion
    return markdown
      .replace(/```(\w*)\n([\s\S]*?)```/g, '
$2
') .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\n/g, '
'); } } // Export the adapter for plugin loading export default MatrixAdapter;

Step 3: Package as a Plugin

// package.json
{
  "name": "@openacp/adapter-matrix",
  "version": "1.0.0",
  "description": "Matrix adapter for OpenACP",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "openacp": {
    "type": "adapter",
    "platform": "matrix"
  },
  "scripts": {
    "build": "tsc",
    "test": "vitest"
  },
  "dependencies": {
    "matrix-js-sdk": "^30.0.0"
  },
  "peerDependencies": {
    "@openacp/core": "^1.0.0"
  }
}

Step 4: Install and Configure

# Install the adapter as a plugin
npm install --prefix ~/.openacp/plugins @openacp/adapter-matrix

# Add Matrix configuration to config.json
{
  "matrix": {
    "homeserverUrl": "https://matrix.org",
    "accessToken": "your_matrix_access_token",
    "userId": "@yourbot:matrix.org",
    "allowedUserIds": ["@alice:matrix.org", "@bob:matrix.org"]
  }
}

# Restart OpenACP
openacp --daemon restart

Plugin Architecture

Plugins are npm packages installed to ~/.openacp/plugins/. At startup, OpenACP scans the plugins directory, reads each package's package.json, and loads plugins based on their declared type.

Plugin Types

Type Purpose Example
adapter Adds support for a new messaging platform Matrix, WhatsApp, IRC
command Adds new chat commands /jira, /deploy, /monitor
middleware Intercepts and transforms messages Content filtering, translation
api Adds new REST API endpoints Webhook handlers, integrations
storage Custom storage backends for sessions/usage PostgreSQL, Redis

Plugin Manifest

Plugins declare their type and capabilities in package.json:

{
  "name": "@openacp/plugin-jira",
  "openacp": {
    "type": "command",
    "commands": ["/jira"],
    "description": "Create and manage Jira issues from chat",
    "minVersion": "1.4.0"
  }
}

Building a Command Plugin

// A plugin that adds a /jira command to create Jira issues
import { CommandPlugin, CommandContext } from '@openacp/core';

export default class JiraPlugin extends CommandPlugin {
  name = 'jira';
  commands = ['/jira'];
  description = 'Create and manage Jira issues';

  private jiraClient: JiraClient;

  async initialize(config: Record<string, unknown>) {
    this.jiraClient = new JiraClient({
      host: config.jiraHost as string,
      email: config.jiraEmail as string,
      apiToken: config.jiraApiToken as string,
    });
  }

  async handleCommand(ctx: CommandContext) {
    const { args, session, adapter } = ctx;

    if (args[0] === 'create') {
      // Use the AI agent to generate issue details
      const issueDetails = await session.askAgent(
        `Based on our conversation, create a Jira issue summary and description for: ${args.slice(1).join(' ')}`
      );

      const issue = await this.jiraClient.createIssue({
        project: 'DEV',
        summary: issueDetails.summary,
        description: issueDetails.description,
        type: 'Task',
      });

      await adapter.sendMessage(
        ctx.channelId,
        `Created Jira issue: ${issue.key} - ${issue.summary}\n${issue.url}`
      );
    }

    if (args[0] === 'status') {
      const issueKey = args[1];
      const issue = await this.jiraClient.getIssue(issueKey);
      await adapter.sendMessage(
        ctx.channelId,
        `${issue.key}: ${issue.summary}\nStatus: ${issue.status}\nAssignee: ${issue.assignee}`
      );
    }
  }
}

Building a Middleware Plugin

// A plugin that translates messages to English before sending to the agent
import { MiddlewarePlugin, MessageContext } from '@openacp/core';

export default class TranslationPlugin extends MiddlewarePlugin {
  name = 'translator';

  async processIncoming(ctx: MessageContext): Promise<MessageContext> {
    // Detect language and translate to English if needed
    const language = await detectLanguage(ctx.content);

    if (language !== 'en') {
      ctx.metadata.originalLanguage = language;
      ctx.metadata.originalContent = ctx.content;
      ctx.content = await translate(ctx.content, language, 'en');
    }

    return ctx;
  }

  async processOutgoing(ctx: MessageContext): Promise<MessageContext> {
    // Translate response back to user's language
    const originalLanguage = ctx.metadata.originalLanguage;

    if (originalLanguage && originalLanguage !== 'en') {
      ctx.content = await translate(ctx.content, 'en', originalLanguage);
    }

    return ctx;
  }
}

Testing with Vitest

OpenACP uses Vitest as its test runner. The test suite covers unit tests for individual components, integration tests for adapter behavior, and end-to-end tests for the complete message flow.

Running Tests

# Clone the repository
git clone https://github.com/Open-ACP/OpenACP.git
cd OpenACP

# Install dependencies
npm install

# Run all tests
npm test

# Run tests in watch mode
npm run test:watch

# Run a specific test file
npx vitest run src/session.test.ts

# Run tests with coverage
npx vitest run --coverage

Writing Tests for Your Adapter

// matrix-adapter.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MatrixAdapter } from './index';

describe('MatrixAdapter', () => {
  let adapter: MatrixAdapter;
  let mockCore: any;

  beforeEach(() => {
    mockCore = {
      logger: { child: () => ({ info: vi.fn(), debug: vi.fn(), warn: vi.fn() }) },
      findSession: vi.fn(),
      createSession: vi.fn(),
    };

    adapter = new MatrixAdapter(
      {
        homeserverUrl: 'https://matrix.test',
        accessToken: 'test-token',
        userId: '@bot:matrix.test',
        allowedUserIds: ['@alice:matrix.test'],
      },
      mockCore
    );
  });

  it('should have the correct adapter name', () => {
    expect(adapter.name).toBe('matrix');
  });

  it('should reject messages from unauthorized users', async () => {
    await adapter['onMessageReceived'](
      '@unauthorized:matrix.test',
      '!room:matrix.test',
      'hello'
    );

    expect(mockCore.createSession).not.toHaveBeenCalled();
  });

  it('should create a session for authorized users', async () => {
    mockCore.findSession.mockReturnValue(null);
    mockCore.createSession.mockResolvedValue({
      id: 'sess_test',
      sendMessage: vi.fn(),
    });

    await adapter['onMessageReceived'](
      '@alice:matrix.test',
      '!room:matrix.test',
      'hello'
    );

    expect(mockCore.createSession).toHaveBeenCalledWith(
      '@alice:matrix.test',
      'matrix',
      expect.any(String)
    );
  });

  it('should split long messages', () => {
    const longMessage = 'a'.repeat(10000);
    const chunks = adapter['splitMessage'](longMessage, 4000);

    expect(chunks.length).toBe(3);
    expect(chunks[0].length).toBe(4000);
    expect(chunks[1].length).toBe(4000);
    expect(chunks[2].length).toBe(2000);
  });
});

Contributing to OpenACP

OpenACP welcomes contributions of all kinds: bug fixes, new features, documentation improvements, and test additions. Here is the complete process for contributing:

Step 1: Fork and Clone

git clone https://github.com/YOUR-USERNAME/OpenACP.git
cd OpenACP
npm install

Step 2: Create a Branch

# Use descriptive branch names
git checkout -b feat/matrix-adapter
git checkout -b fix/session-timeout-race-condition
git checkout -b docs/plugin-development-guide

Step 3: Make Your Changes

Follow these code conventions:

Step 4: Write Tests

All new features and bug fixes should include tests. The test directory mirrors the source directory structure:

src/
  adapters/
    telegram.ts          # Source
    telegram.test.ts     # Tests
  session.ts
  session.test.ts
  core.ts
  core.test.ts

Aim for meaningful test coverage that covers the happy path, error cases, and edge cases. Do not test implementation details -- test behavior.

Step 5: Run the Full Test Suite

# Lint
npm run lint

# Type check
npm run typecheck

# Tests
npm test

# Build
npm run build

Step 6: Submit a Pull Request

Push your branch and create a pull request on GitHub. The PR should include:

The CI pipeline will automatically run lint, type checking, tests, and a build. All checks must pass before the PR can be merged.

Code Review Process

Maintainers will review your PR and may request changes. The review focuses on:

Community

OpenACP is more than a codebase -- it is a community of developers building the future of AI-assisted coding. Here is how to get involved:

Good First Issues

If you are new to the project, look for issues labeled good first issue on GitHub. These are typically small, well-defined tasks that do not require deep knowledge of the codebase:

Roadmap

The project roadmap is maintained in the GitHub repository. Current priorities include:

Wrapping Up

OpenACP's architecture is intentionally extensible. The clean separation between core, sessions, agents, and adapters means you can add new capabilities at the right level of abstraction. The plugin system lets you distribute your extensions as npm packages. And the contribution process is designed to be welcoming and straightforward.

Whether you are building a custom adapter for your favorite messaging platform, creating a plugin that integrates with your team's tools, or fixing a bug you encountered, you are making AI-assisted coding better for everyone. The codebase is well-tested, the conventions are documented, and the community is ready to help.

Start by reading the code, running the tests, and picking a good first issue. We look forward to seeing your pull request.

Start Building with OpenACP

Fork the repo, install locally, and start extending OpenACP with your own plugins and adapters.

git clone https://github.com/Open-ACP/OpenACP.git && npm install