Skip to content

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.



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 subscriptions

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

Define addon-specific types under src/domain/. These types must not depend on adapters/ or other addons.

src/domain/my-concept.ts
export interface MyConcept {
readonly id: string;
readonly projectPath: string;
readonly content: string;
readonly createdAt: string;
}

The inbound port defines what your addon exposes to HTTP routes and composition.

src/ports/for-my-addon.ts
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[]>;
}

src/ports/my-store.ts
import type { MyConcept } from '../domain/my-concept.js';
export interface MyStore {
save(concept: MyConcept): Promise<void>;
findAll(projectPath: string): Promise<readonly MyConcept[]>;
}

Implement the store via the core-provided Filesystem port (never fs directly).

src/adapters/fs-my-store.ts
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.


src/use-cases/build-for-my-addon.ts
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);
},
};
}

The addon subscribes to bus events to react to system events.

src/use-cases/event-subscriber.ts
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.


If your addon must inject context before a chat turn, implement BeforeTurnAugmenter:

src/use-cases/build-augmenter.ts
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`;
},
};
}

src/index.ts
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 };
}

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.


Create fakes for your outbound ports and test use-cases in isolation:

src/use-cases/build-for-my-addon.test.ts
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');
});
});

  • manifest.json complete and valid
  • registerMyAddon exports unsubscribe
  • 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 lint and npm test green