Write an arka-deck addon
This tutorial explains how to create an addon from scratch by following the patterns of existing addons (cortex-actions, memory-local, gouvernance-lite).
For the formal reference of types and contracts, see contrat-addon.
Prerequisites
Section titled “Prerequisites”- Understand the hexagonal architecture
- Understand the event bus
- Read an existing addon —
addons/memory-local/src/is the simplest
1. Folder structure
Section titled “1. Folder structure”Create your addon under addons/<your-addon>/:
addons/my-addon/├── manifest.json└── src/ ├── index.ts # entry point: register function + re-exports ├── domain/ │ └── my-concept.ts # addon-local domain types ├── ports/ │ ├── for-my-addon.ts # inbound port (exposed use-cases) │ └── my-store.ts # outbound port (storage) ├── adapters/ │ └── fs-my-store.ts # store impl via Filesystem └── use-cases/ ├── build-for-my-addon.ts # use-cases assembler └── event-subscriber.ts # bus subscriptions2. manifest.json
Section titled “2. manifest.json”{ "name": "my-addon", "version": "1.0.0", "kind": "capability", "description": "What my addon does in one sentence.", "status": "active", "workers": [], "dependencies": [], "entrypoint": "src/index.ts"}3. Domain types
Section titled “3. Domain types”Define addon-specific types under src/domain/. These types must not depend on adapters/ or other addons.
export interface MyConcept { readonly id: string; readonly projectPath: string; readonly content: string; readonly createdAt: string;}4. Inbound port (use cases)
Section titled “4. Inbound port (use cases)”The inbound port defines what your addon exposes to HTTP routes and composition.
import type { MyConcept } from '../domain/my-concept.js';
export interface SaveConceptInput { readonly projectPath: string; readonly content: string;}
export interface ForMyAddon { saveConcept(input: SaveConceptInput): Promise<MyConcept>; getConcepts(projectPath: string): Promise<readonly MyConcept[]>;}5. Outbound port (store)
Section titled “5. Outbound port (store)”import type { MyConcept } from '../domain/my-concept.js';
export interface MyStore { save(concept: MyConcept): Promise<void>; findAll(projectPath: string): Promise<readonly MyConcept[]>;}6. Filesystem adapter
Section titled “6. Filesystem adapter”Implement the store via the core-provided Filesystem port (never fs directly).
import type { Filesystem } from '../../../../core/ports/outbound/filesystem.js';import type { MyStore } from '../ports/my-store.js';import type { MyConcept } from '../domain/my-concept.js';
const STORE_PATH = (projectPath: string) => `${projectPath}/.arka-deck/addons/my-addon/concepts.json`;
export class FsMyStore implements MyStore { constructor(private readonly fs: Filesystem) {}
async save(concept: MyConcept): Promise<void> { const path = STORE_PATH(concept.projectPath); const existing = await this.findAll(concept.projectPath); const updated = [...existing, concept]; await this.fs.writeJson(path, updated); }
async findAll(projectPath: string): Promise<readonly MyConcept[]> { const path = STORE_PATH(projectPath); const raw = await this.fs.readJson(path).catch(() => null); if (!Array.isArray(raw)) return []; return raw as MyConcept[]; }}Storage path: always under
<projectPath>/.arka-deck/addons/<your-addon>/. Never write outside.
7. Use cases
Section titled “7. Use cases”import type { Clock } from '../../../../core/ports/outbound/clock.js';import type { IdGenerator } from '../../../../core/ports/outbound/id-generator.js';import type { MyStore } from '../ports/my-store.js';import type { ForMyAddon, SaveConceptInput } from '../ports/for-my-addon.js';
export function buildForMyAddon(deps: { clock: Clock; idGenerator: IdGenerator; store: MyStore;}): ForMyAddon { return { async saveConcept(input: SaveConceptInput) { const concept = { id: deps.idGenerator.next(), projectPath: input.projectPath, content: input.content, createdAt: deps.clock.now().toISOString(), }; await deps.store.save(concept); return concept; },
async getConcepts(projectPath: string) { return deps.store.findAll(projectPath); }, };}8. Bus subscriber
Section titled “8. Bus subscriber”The addon subscribes to bus events to react to system events.
import type { EventBus } from '../../../../core/ports/outbound/event-bus.js';import type { ForMyAddon } from '../ports/for-my-addon.js';
export interface SubscriberHandle { readonly unsubscribe: () => void;}
export interface SubscriberDeps { readonly eventBus: EventBus; readonly forMyAddon: ForMyAddon; readonly resolveProjectPath: (sessionId: string) => Promise<string | null>;}
export function subscribeMyAddonToEventBus(deps: SubscriberDeps): SubscriberHandle { const unsubEnded = deps.eventBus.subscribe('chat.session.ended', async (ev) => { const projectPath = await deps.resolveProjectPath(ev.sessionId).catch(() => null); if (projectPath === null) return;
await deps.forMyAddon.saveConcept({ projectPath, content: `Session ${ev.sessionId} ended (${ev.reason})`, }).catch(() => undefined); });
return { unsubscribe: () => { unsubEnded(); }, };}Important rule: never throw in a bus handler. Use .catch(() => undefined) for best-effort operations. An error in your handler does not impact other subscribers.
9. BeforeTurnAugmenter (optional)
Section titled “9. BeforeTurnAugmenter (optional)”If your addon must inject context before a chat turn, implement BeforeTurnAugmenter:
import type { BeforeTurnAugmenter, BeforeTurnAugmentInput } from '../../../../core/ports/outbound/before-turn-augmenter.js';import type { ForMyAddon } from '../ports/for-my-addon.js';
export function buildMyAddonAugmenter(deps: { forMyAddon: ForMyAddon; resolveProjectPath: (sessionId: string) => Promise<string | null>;}): BeforeTurnAugmenter { return { async augmentPrompt(input: BeforeTurnAugmentInput): Promise<string | null> { const projectPath = await deps.resolveProjectPath(input.sessionId).catch(() => null); if (projectPath === null) return null;
const concepts = await deps.forMyAddon.getConcepts(projectPath).catch(() => []); if (concepts.length === 0) return null;
return `[my-addon context]\n${concepts.map((c) => `- ${c.content}`).join('\n')}\n`; }, };}10. Entry point (register function)
Section titled “10. Entry point (register function)”import type { Clock } from '../../../core/ports/outbound/clock.js';import type { EventBus } from '../../../core/ports/outbound/event-bus.js';import type { Filesystem } from '../../../core/ports/outbound/filesystem.js';import type { IdGenerator } from '../../../core/ports/outbound/id-generator.js';import type { BeforeTurnAugmenter } from '../../../core/ports/outbound/before-turn-augmenter.js';
import { FsMyStore } from './adapters/fs-my-store.js';import type { ForMyAddon } from './ports/for-my-addon.js';import { buildForMyAddon } from './use-cases/build-for-my-addon.js';import { buildMyAddonAugmenter } from './use-cases/build-augmenter.js';import { subscribeMyAddonToEventBus } from './use-cases/event-subscriber.js';
export type { ForMyAddon, SaveConceptInput } from './ports/for-my-addon.js';export type { MyConcept } from './domain/my-concept.js';
export interface MyAddonDeps { readonly clock: Clock; readonly idGenerator: IdGenerator; readonly filesystem: Filesystem; readonly eventBus: EventBus; readonly resolveProjectPath: (sessionId: string) => Promise<string | null>;}
export interface MyAddonRuntime { readonly forMyAddon: ForMyAddon; readonly beforeTurnAugmenter: BeforeTurnAugmenter; readonly unsubscribe: () => void;}
export function registerMyAddon(deps: MyAddonDeps): MyAddonRuntime { const store = new FsMyStore(deps.filesystem);
const forMyAddon = buildForMyAddon({ clock: deps.clock, idGenerator: deps.idGenerator, store, });
const { unsubscribe } = subscribeMyAddonToEventBus({ eventBus: deps.eventBus, forMyAddon, resolveProjectPath: deps.resolveProjectPath, });
const beforeTurnAugmenter = buildMyAddonAugmenter({ forMyAddon, resolveProjectPath: deps.resolveProjectPath, });
return { forMyAddon, beforeTurnAugmenter, unsubscribe };}11. Composition registration
Section titled “11. Composition registration”Open composition/core-container.ts and add your addon to the addons section:
import { registerMyAddon } from '../addons/my-addon/src/index.js';
const myAddonRuntime = registerMyAddon({ clock, idGenerator, filesystem, eventBus, resolveProjectPath: (sessionId) => chatSessionStore.getSession(sessionId) .then((s) => s?.projectPath ?? null),});If your addon exposes HTTP routes, wire them in composition/addons/ and register them in web-container.ts.
12. Tests
Section titled “12. Tests”Create fakes for your outbound ports and test use-cases in isolation:
import { describe, it, expect } from 'vitest';import { buildForMyAddon } from './build-for-my-addon.js';
const fakeClock = { now: () => new Date('2026-01-01T00:00:00Z') };const fakeIdGenerator = { next: () => 'test-id-1' };const fakeStore = { concepts: [] as MyConcept[], async save(c: MyConcept) { this.concepts.push(c); }, async findAll() { return this.concepts; },};
describe('buildForMyAddon', () => { it('saves and retrieves a concept', async () => { const forMyAddon = buildForMyAddon({ clock: fakeClock, idGenerator: fakeIdGenerator, store: fakeStore, });
await forMyAddon.saveConcept({ projectPath: '/tmp/proj', content: 'test' }); const concepts = await forMyAddon.getConcepts('/tmp/proj');
expect(concepts).toHaveLength(1); expect(concepts[0].content).toBe('test'); });});Checklist before contributing
Section titled “Checklist before contributing”-
manifest.jsoncomplete and valid -
registerMyAddonexportsunsubscribe - Bus handlers with
.catch(() => undefined)(best-effort) - File writes only under
.arka-deck/addons/<my-addon>/ - No import to
adapters/,composition/or other addons - Unit tests on use-cases
-
npm run lintandnpm testgreen