Plugin Development
a16n uses a plugin architecture to support different AI coding tools. Each plugin handles:
- Discovery - Finding agent customization files in a project
- Emission - Writing converted files to the target format
Plugin Interface
Plugins implement the A16nPlugin interface from @a16njs/models; see the Models API Reference for complete interface documentation.
The key methods are:
discover(rootOrWorkspace)- Scan a directory and return found customizationsemit(models, rootOrWorkspace, options)- Write customizations to disk in the plugin's format
Both methods accept string | Workspace — a plain directory path string or a Workspace instance. When a string is passed, plugins should wrap it using toWorkspace() from @a16njs/models.
import type { A16nPlugin, Workspace } from '@a16njs/models';
import { CustomizationType, toWorkspace } from '@a16njs/models';
const myPlugin: A16nPlugin = {
id: 'my-agent',
name: 'My Agent',
supports: [CustomizationType.GlobalPrompt, CustomizationType.FileRule],
async discover(rootOrWorkspace: string | Workspace) {
const ws = toWorkspace(rootOrWorkspace, 'my-agent-discover');
// Return { items: [...], warnings: [...] }
},
async emit(models, rootOrWorkspace, options) {
const ws = toWorkspace(rootOrWorkspace, 'my-agent-emit');
// Return { written: [...], warnings: [...], unsupported: [...] }
}
};
export default myPlugin;
Key Concepts
Customization Types
Plugins declare which types they support via the supports array. Entries are instances of CustomizationType from @a16njs/models.
Workspace Interface
The Workspace interface abstracts filesystem operations so plugins can work with real directories or in-memory workspaces (useful for testing). All paths passed to workspace methods are relative to the workspace root.
import type { Workspace } from '@a16njs/models';
// Available methods:
await ws.exists('path/to/file'); // boolean
await ws.read('path/to/file'); // string (UTF-8)
await ws.write('path/to/file', content); // void
await ws.readdir('path/to/dir'); // WorkspaceEntry[]
await ws.mkdir('path/to/dir'); // void (always recursive)
ws.resolve('path/to/file'); // absolute path string
WorkspaceEntry items returned by readdir() have boolean properties isFile and isDirectory (not methods).
Use toWorkspace() to normalize the string | Workspace parameter at the top of your functions:
import { toWorkspace } from '@a16njs/models';
const ws = toWorkspace(rootOrWorkspace, 'my-plugin-discover');
// ws is always a Workspace — strings are auto-wrapped in LocalWorkspace
Discovery
Discovery scans a project directory and returns customizations in the intermediate representation:
async discover(rootOrWorkspace: string | Workspace): Promise<DiscoveryResult> {
const ws = toWorkspace(rootOrWorkspace, 'my-plugin-discover');
const items: AgentCustomization[] = [];
const warnings: Warning[] = [];
// Use ws.readdir(), ws.read(), ws.exists() to scan for config files
// Parse and convert to AgentCustomization items
return { items, warnings };
}
Emission
Emission converts intermediate representation back to the plugin's native format:
async emit(
items: AgentCustomization[],
rootOrWorkspace: string | Workspace,
options?: EmitOptions
): Promise<EmitResult> {
const ws = toWorkspace(rootOrWorkspace, 'my-plugin-emit');
const written: WrittenFile[] = [];
const warnings: Warning[] = [];
const unsupported: AgentCustomization[] = [];
for (const item of items) {
if (this.supports.includes(item.type)) {
// Use ws.mkdir(), ws.write() to write files
// Use ws.resolve(relPath) for written[].path
} else {
unsupported.push(item);
}
}
return { written, warnings, unsupported };
}
Project Structure
Recommended plugin structure:
a16n-plugin-example/
├── src/
│ ├── index.ts # Plugin entry point & default export
│ ├── discover.ts # Discovery logic
│ └── emit.ts # Emission logic
├── test/
│ ├── fixtures/ # Test fixtures
│ ├── discover-*.test.ts # discovery tests (split by domain as the suite grows)
│ └── emit-*.test.ts # emission tests (split by domain as the suite grows)
├── package.json
├── tsconfig.json
└── README.md
package.json Requirements
Your package.json must:
- Use the
a16n-plugin-prefix in the package name (required for auto-discovery) - Set
mainto point to your built entry file - Declare
@a16njs/modelsas apeerDependency
{
"name": "a16n-plugin-example",
"type": "module",
"main": "./dist/index.js",
"peerDependencies": {
"@a16njs/models": "^0.9.0"
},
"devDependencies": {
"@a16njs/models": "^0.9.0"
}
}
Testing Your Plugin
File Organization
Use one test file per behavior domain. Grow the suite as vertical slices, not as rows in a monolith:
test/
├── fixtures/ # Shared fixture directories
├── test-support/
│ ├── emit-helpers.ts # suiteTempDir() — per-suite temp isolation for emit tests
│ └── discover-helpers.ts # discoverFixturesDir() — fixture path resolution
├── discover-cursor-plugin.test.ts
├── discover-mdc-parsing.test.ts
├── emit-global-prompt.test.ts
└── emit-file-rule.test.ts
Each discover-*.test.ts covers one top-level discovery concern; each emit-*.test.ts covers one top-level emission concern. One file per root describe block — do not let a single file accumulate multiple unrelated domains.
Per-Suite Temp Directory Isolation (emit tests)
Vitest runs test files in parallel by default. Emit tests write to disk, so each suite must use a unique temp root. The recommended pattern:
// test/test-support/emit-helpers.ts
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import fs from 'node:fs';
export function suiteTempDir(importMetaUrl: string, slug: string): string {
const dir = path.join(
path.dirname(fileURLToPath(importMetaUrl)),
'.temp-emit',
slug
);
return dir;
}
// In each emit-*.test.ts:
import { suiteTempDir } from './test-support/emit-helpers.js';
const tempDir = suiteTempDir(import.meta.url, 'my-domain');
beforeEach(async () => { await fs.promises.mkdir(tempDir, { recursive: true }); });
afterEach(async () => { await fs.promises.rm(tempDir, { recursive: true, force: true }); });
Fixture Path Resolution (discover tests)
Discovery tests are read-only. Resolve fixtures relative to the test file so they work correctly regardless of Vitest's working directory:
// test/test-support/discover-helpers.ts
import { fileURLToPath } from 'node:url';
import path from 'node:path';
export function discoverFixturesDir(importMetaUrl: string): string {
return path.join(path.dirname(fileURLToPath(importMetaUrl)), '..', 'fixtures');
}
// In each discover-*.test.ts:
import { discoverFixturesDir } from './test-support/discover-helpers.js';
const fixturesDir = discoverFixturesDir(import.meta.url);
Learning from Existing Plugins
The best way to understand plugin development is to study the existing implementations:
The Cursor plugin demonstrates:
- MDC file parsing (YAML frontmatter + markdown body)
- Multiple file types (rules, commands, ignore files)
- Frontmatter-based type classification
The Claude plugin demonstrates:
- Settings JSON handling
The cursorrules plugin demonstrates:
- Community plugin naming format (
a16n-plugin-*) - Discovery-only support (no emission)
Publishing
Community plugins should be published to npm with the a16n-plugin- prefix:
a16n-plugin-<name>
a16n automatically discovers installed packages matching the a16n-plugin-* naming convention by scanning node_modules directories. No registration or configuration is needed - just npm install the plugin alongside a16n.
The @a16njs/plugin-* scoped packages are reserved for bundled plugins maintained in the a16n monorepo. Community plugins should use the unscoped a16n-plugin-* convention.
See Also
- Models API Reference - Plugin interface documentation
- Plugins:
- Plugin: Cursor - Cursor implementation details
- Plugin: Claude - Claude implementation details
- Plugin: a16n - a16n implementation details
- Community plugins following the
a16n-plugin-*naming convention can be built using this guide
- Understanding Conversions - Translation can be hard!
- GitHub Repository - Source code