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:
- Language: TypeScript with strict mode enabled
- Formatting: Prettier with the project's
.prettierrcconfiguration - Linting: ESLint with the project's
.eslintrcconfiguration - Naming: camelCase for variables and functions, PascalCase for classes and types
- Imports: Use named imports, avoid default exports (except for plugin entry points)
- Error handling: Use custom error classes that extend
OpenACPError - Logging: Use the injected Pino logger, never
console.log - Configuration: All new config options must have Zod schema validation
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:
- A clear title describing the change
- A description of what the change does and why
- Any relevant issue numbers (e.g., "Fixes #42")
- Screenshots or recordings if the change affects the UI
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:
- Correctness: Does the code do what it claims?
- Safety: Could this change introduce security issues?
- Performance: Are there any obvious performance concerns?
- Consistency: Does the code follow existing patterns and conventions?
- Tests: Are the tests adequate and meaningful?
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:
- GitHub Discussions: Ask questions, propose features, and share ideas at github.com/Open-ACP/OpenACP/discussions
- Issues: Report bugs and request features at github.com/Open-ACP/OpenACP/issues
- Twitter/X: Follow @Open_ACP for announcements and updates
- ACP Protocol: The underlying protocol is open at agentclientprotocol.org
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:
- Adding input validation to an API endpoint
- Improving error messages for common misconfigurations
- Adding a new Edge TTS voice option
- Writing documentation for an undocumented feature
- Adding test coverage for an untested code path
Roadmap
The project roadmap is maintained in the GitHub repository. Current priorities include:
- Additional platform adapters (WhatsApp, Matrix, IRC)
- Multi-agent sessions (multiple agents collaborating on one task)
- Enhanced plugin API with more extension points
- Web-based admin dashboard
- Improved context resume with local storage option
- Agent marketplace for pre-configured agent profiles
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