Skip to main content

Write a memory backend plugin

Task

Create a plugin that registers a custom memory provider into the memory provider registry. Once installed, set memory: <your-plugin-id>/<name> in ~/.ethos/config.yaml to route all memory reads and writes through your backend.

Result

prefetch() runs at session start and injects your stored memory into the system prompt. sync() runs after each turn and persists the agent's memory updates. Per-personality memory routing resolves your provider by name from the registry. The 5-method contract (prefetch, read, search, sync, list) stays frozen; your implementation slots in without any core changes.

Prereqs

  • TypeScript familiarity, Node 24+, pnpm on PATH.
  • A backend ready to talk to (Mem0, Letta, Zep, Redis, a custom API).
  • Understanding of the MemoryProvider interface (5 methods in packages/types/src/memory.ts).

Steps

1. Scaffold the plugin

package.json
{
"name": "ethos-plugin-mem0",
"version": "1.0.0",
"description": "Mem0 memory backend for Ethos",
"main": "src/index.ts",
"ethos": {
"type": "plugin",
"pluginContractMajor": 2
},
"dependencies": {
"@ethosagent/types": "workspace:*"
}
}

2. Implement MemoryProvider

The 5-method contract:

src/mem0-provider.ts
import type {
MemoryContext,
MemoryEntryRef,
MemoryProvider,
MemoryUpdate,
} from '@ethosagent/types';

interface MemoryCtx {
scopeId: string;
sessionId: string;
sessionKey: string;
platform: string;
workingDir: string;
}

export class Mem0Provider implements MemoryProvider {
constructor(
private readonly apiKey: string,
private readonly baseUrl: string,
) {}

async prefetch(ctx: MemoryCtx): Promise<{ content: string; keys: string[] } | null> {
const memories = await this.fetchMemories(ctx.scopeId);
if (memories.length === 0) return null;
const content = memories.map((m) => `- ${m.text}`).join('\n');
return { content, keys: ['MEMORY.md', 'USER.md'] };
}

async read(key: string, ctx: MemoryCtx): Promise<string | null> {
const memories = await this.fetchMemories(ctx.scopeId, key);
if (memories.length === 0) return null;
return memories.map((m) => m.text).join('\n');
}

async search(query: string, ctx: MemoryCtx): Promise<string[]> {
const res = await fetch(`${this.baseUrl}/v1/memories/search`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({ query, user_id: ctx.scopeId, limit: 10 }),
});
const data = (await res.json()) as { results: { memory: string }[] };
return data.results.map((r) => r.memory);
}

async sync(updates: MemoryUpdate[], ctx: MemoryCtx): Promise<void> {
for (const update of updates) {
if (update.action === 'add') {
await fetch(`${this.baseUrl}/v1/memories`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({
messages: [{ role: 'user', content: update.content }],
user_id: ctx.scopeId,
metadata: { key: update.key },
}),
});
} else if (update.action === 'replace') {
await this.deleteByKey(ctx.scopeId, update.key);
await fetch(`${this.baseUrl}/v1/memories`, {
method: 'POST',
headers: this.headers(),
body: JSON.stringify({
messages: [{ role: 'user', content: update.content }],
user_id: ctx.scopeId,
metadata: { key: update.key },
}),
});
}
}
}

async list(ctx: MemoryCtx): Promise<MemoryEntryRef[]> {
const memories = await this.fetchMemories(ctx.scopeId);
const keys = new Set(memories.map((m) => m.metadata?.key ?? 'MEMORY.md'));
return [...keys].map((key) => ({ key }));
}

private async fetchMemories(userId: string, key?: string) {
const url = new URL(`${this.baseUrl}/v1/memories`);
url.searchParams.set('user_id', userId);
const res = await fetch(url, { headers: this.headers() });
const data = (await res.json()) as { results: { text: string; metadata?: { key?: string } }[] };
if (key) return data.results.filter((m) => m.metadata?.key === key);
return data.results;
}

private async deleteByKey(userId: string, key: string) {
const memories = await this.fetchMemories(userId, key);
for (const m of memories) {
await fetch(`${this.baseUrl}/v1/memories/${(m as { id: string }).id}`, {
method: 'DELETE',
headers: this.headers(),
});
}
}

private headers() {
return { Authorization: `Token ${this.apiKey}`, 'Content-Type': 'application/json' };
}
}

3. Register via activate

src/index.ts
import type { EthosPluginApi } from '@ethosagent/plugin-sdk';
import { Mem0Provider } from './mem0-provider';

export function activate(api: EthosPluginApi): void {
api.registerMemoryProvider('mem0', async ({ secrets, logger }) => {
const apiKey = await secrets.get('providers/mem0/apiKey');
if (!apiKey) {
throw new Error('Mem0 API key not found in secrets store');
}
logger.info('Mem0 memory provider activated');
return new Mem0Provider(apiKey, 'https://api.mem0.ai');
});
}

The registered name becomes ethos-plugin-mem0/mem0.

4. Configure

~/.ethos/config.yaml
memory: ethos-plugin-mem0/mem0
ethos secrets set providers/mem0/apiKey <your-key>

5. Per-personality routing (optional)

A personality can override the global memory backend:

~/.ethos/personalities/researcher/config.yaml
memory:
provider: ethos-plugin-mem0/mem0
options:
collection: researcher

The options dict is passed as config to your factory.

Verify

ethos chat -q "remember that I prefer dark mode"
ethos chat -q "what are my preferences?"

The second turn should recall the preference via your Mem0 backend.

Troubleshoot

"Memory provider X is not registered" — The plugin did not load. Check ethos plugins list and ensure ethos.pluginContractMajor: 2 is set.

prefetch returns null but memories exist — Your fetchMemories call may be scoping incorrectly. The scopeId is personality:<id> for per-personality scope or global for shared.

Factory throws "API key not found" — Run ethos secrets set providers/mem0/apiKey <key>. Network-only backends still need credentials even though they ignore dataDir.

The drift-gate rejects your plugin — Your package.json must declare ethos.pluginContractMajor: 2. Plugins declaring v1 (or omitting the field from older versions) are accepted; plugins declaring a future major are rejected.