Mom Redesign: Multi-Platform Chat Support¶
Goals¶
- Support multiple chat platforms (Slack, Discord, WhatsApp, Telegram, etc.)
- Unified storage layer for all platforms
- Platform-agnostic agent that doesn't care where messages come from
- Adapters that are independently testable
- Agent that is independently testable
Current Architecture Problems¶
The current architecture tightly couples Slack-specific code throughout:
main.ts → SlackBot → handler.handleEvent() → agent.run(SlackContext)
↓
SlackContext.respond()
SlackContext.replaceMessage()
SlackContext.respondInThread()
etc.
Problems:
- SlackContext interface leaks Slack concepts (threads, typing indicators)
- Agent code references Slack-specific formatting (mrkdwn, <@user> mentions)
- Storage uses Slack timestamps (ts) as message IDs
- Message logging assumes Slack's event structure
- The PR's Discord implementation duplicated most of this logic in a separate package
Proposed Architecture¶
┌─────────────────────────────────────────────────────────────────────────┐
│ CLI / Entry Point │
│ mom ./data │
│ (reads config.json, starts all configured adapters) │
└───────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Platform Adapter │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ SlackAdapter │ │DiscordAdapter│ │ CLIAdapter │ (for testing) │
│ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │ │
│ └────────────────┬┴─────────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ PlatformAdapter │ (common interface) │
│ │ - onMessage() │ │
│ │ - onStop() │ │
│ │ - sendMessage() │ │
│ │ - updateMessage() │ │
│ │ - deleteMessage() │ │
│ │ - uploadFile() │ │
│ │ - getChannelInfo() │ │
│ │ - getUserInfo() │ │
│ └───────────┬───────────┘ │
└──────────────────────────┼──────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ MomAgent │
│ - Platform agnostic │
│ - Receives messages via handleMessage(message, context, onEvent) │
│ - Forwards AgentSessionEvent to adapter via callback │
│ - Provides: abort(), isRunning() │
└───────────────────────────────────┬─────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ ChannelStore │
│ - Unified storage schema for all platforms │
│ - log.jsonl: channel history (messages only) │
│ - context.jsonl: LLM context (messages + tool results) │
│ - attachments/: downloaded files │
└─────────────────────────────────────────────────────────────────────────┘
Key Interfaces¶
1. ChannelMessage (Unified Message Format)¶
interface ChannelMessage {
/** Unique ID within the channel (platform-specific format preserved) */
id: string;
/** Channel/conversation ID */
channelId: string;
/** Timestamp (ISO 8601) */
timestamp: string;
/** Sender info */
sender: {
id: string;
username: string;
displayName?: string;
isBot: boolean;
};
/** Message content (as received from platform) */
text: string;
/** Optional: original platform-specific text (for debugging) */
rawText?: string;
/** Attachments */
attachments: ChannelAttachment[];
/** Is this a direct mention/trigger of the bot? */
isMention: boolean;
/** Optional: reply-to message ID (for threaded conversations) */
replyTo?: string;
/** Platform-specific metadata (for platform-specific features) */
metadata?: Record<string, unknown>;
}
interface ChannelAttachment {
/** Original filename */
filename: string;
/** Local path (relative to channel dir) */
localPath: string;
/** MIME type if known */
mimeType?: string;
/** File size in bytes */
size?: number;
}
2. PlatformAdapter¶
Adapters handle platform connection and UI. They receive events from MomAgent and render however they want.
interface PlatformAdapter {
/** Adapter name (used in channel paths, e.g., "slack-acme") */
name: string;
/** Start the adapter (connect to platform) */
start(): Promise<void>;
/** Stop the adapter */
stop(): Promise<void>;
/** Get all known channels */
getChannels(): ChannelInfo[];
/** Get all known users */
getUsers(): UserInfo[];
}
interface ChannelInfo {
id: string;
name: string;
type: 'channel' | 'dm' | 'group';
}
interface UserInfo {
id: string;
username: string;
displayName?: string;
}
3. MomAgent¶
MomAgent wraps AgentSession from coding-agent. Agent is platform-agnostic; it just forwards events to the adapter.
import { type AgentSessionEvent } from "@mariozechner/pi-coding-agent";
interface MomAgent {
/**
* Handle an incoming message.
* Adapter receives events via callback and renders however it wants.
*/
handleMessage(
message: ChannelMessage,
context: ChannelContext,
onEvent: (event: AgentSessionEvent) => Promise<void>
): Promise<{ stopReason: string; errorMessage?: string }>;
/** Abort the current run for a channel */
abort(channelId: string): void;
/** Check if a channel is currently running */
isRunning(channelId: string): boolean;
}
interface ChannelContext {
/** Adapter name (for channel path: channels/<adapter>/<channelId>/) */
adapter: string;
users: UserInfo[];
channels: ChannelInfo[];
}
Event Handling¶
Adapter receives AgentSessionEvent and renders however it wants:
// Slack adapter example
async function handleEvent(event: AgentSessionEvent, ctx: SlackContext) {
switch (event.type) {
case 'tool_execution_start': {
const label = (event.args as any).label || event.toolName;
await ctx.updateMain(`_→ ${label}_`);
break;
}
case 'tool_execution_end': {
// Format tool result for thread
const result = extractText(event.result);
const formatted = `**${event.toolName}** (${event.durationMs}ms)\n\`\`\`\n${result}\n\`\`\``;
await ctx.appendThread(this.toSlackFormat(formatted));
break;
}
case 'message_end': {
if (event.message.role === 'assistant') {
const text = extractAssistantText(event.message);
await ctx.replaceMain(this.toSlackFormat(text));
await ctx.appendThread(this.toSlackFormat(text));
// Usage from AssistantMessage
if (event.message.usage) {
await ctx.appendThread(formatUsage(event.message.usage));
}
}
break;
}
case 'auto_compaction_start':
await ctx.updateMain('_Compacting context..._');
break;
}
}
Each adapter decides: - Message formatting (markdown → mrkdwn, embeds, etc.) - Message splitting for platform limits - What goes in main message vs thread - How to show tool results, usage, errors
Storage Format¶
log.jsonl (Channel History)¶
Messages stored as received from platform:
{"id":"1734567890.123456","ts":"2024-12-20T10:00:00.000Z","sender":{"id":"U123","username":"mario","displayName":"Mario Z","isBot":false},"text":"<@U789> what's the weather?","attachments":[],"isMention":true}
{"id":"1734567890.234567","ts":"2024-12-20T10:00:05.000Z","sender":{"id":"bot","username":"mom","isBot":true},"text":"The weather is sunny!","attachments":[]}
context.jsonl (LLM Context)¶
Same format as current (coding-agent compatible):
{"type":"session","id":"uuid","timestamp":"...","provider":"anthropic","modelId":"claude-sonnet-4-5"}
{"type":"message","timestamp":"...","message":{"role":"user","content":"[mario]: what's the weather?"}}
{"type":"message","timestamp":"...","message":{"role":"assistant","content":[{"type":"text","text":"The weather is sunny!"}]}}
Directory Structure¶
data/
├── config.json # Host only - tokens, adapters, access control
└── workspace/ # Mounted as /workspace in Docker
├── MEMORY.md
├── skills/
├── tools/
├── events/
└── channels/
├── slack-acme/
│ └── C0A34FL8PMH/
│ ├── MEMORY.md
│ ├── log.jsonl
│ ├── context.jsonl
│ ├── attachments/
│ ├── skills/
│ └── scratch/
└── discord-mybot/
└── 1234567890123456789/
└── ...
config.json (not mounted, stays on host):
{
"adapters": {
"slack-acme": {
"type": "slack",
"botToken": "xoxb-...",
"appToken": "xapp-...",
"admins": ["U123", "U456"],
"dm": "everyone"
},
"discord-mybot": {
"type": "discord",
"botToken": "...",
"admins": ["123456789"],
"dm": "none"
}
}
}
Access control:
- admins: User IDs with admin privileges. Can always DM.
- dm: Who else can DM. "everyone", "none", or ["U789", "U012"]
Channels are namespaced by adapter name: channels/<adapter>/<channelId>/
Events use qualified channelId: {"channelId": "slack-acme/C123", ...}
Security note: Mom has bash access to all channel logs in the workspace. If mom is in a private channel, anyone who can talk to mom could potentially access that channel's history. For true isolation, run separate mom instances with separate data directories.
Channel Isolation via Bubblewrap (Linux/Docker)¶
In Linux-based execution environments (Docker), we can use bubblewrap to enforce per-user channel access at the OS level.
How it works: 1. Adapter knows which channels the requesting user has access to 2. Before executing bash, wrap command with bwrap 3. Mount entire filesystem, then overlay denied channels with empty tmpfs 4. Sandboxed process can't see files in denied channels
function wrapWithBwrap(command: string, deniedChannels: string[]): string {
const args = [
'--bind / /', // Mount everything
...deniedChannels.map(ch =>
`--tmpfs /workspace/channels/${ch}` // Hide denied channels
),
'--dev /dev',
'--proc /proc',
'--die-with-parent',
];
return `bwrap ${args.join(' ')} -- ${command}`;
}
// Usage
const userChannels = adapter.getUserChannels(userId); // ["public", "team-a"]
const allChannels = await fs.readdir('/workspace/channels/');
const denied = allChannels.filter(ch => !userChannels.includes(ch));
const sandboxedCmd = wrapWithBwrap('cat /workspace/channels/private/log.jsonl', denied);
// Results in: "No such file or directory" - private channel hidden
Requirements:
- Docker container needs --cap-add=SYS_ADMIN for bwrap to create namespaces
- Install in Dockerfile: apk add bubblewrap
Limitations: - Linux only (not macOS host mode) - Requires SYS_ADMIN capability in Docker - Per-execution overhead (though minimal)
System Prompt Changes¶
The system prompt is platform-agnostic. Agent outputs standard markdown, adapter converts.
function buildSystemPrompt(
workspacePath: string,
channelId: string,
memory: string,
sandbox: SandboxConfig,
context: ChannelContext,
skills: Skill[]
): string {
return `You are mom, a chat bot assistant. Be concise. No emojis.
## Text Formatting
Use standard markdown: **bold**, *italic*, \`code\`, \`\`\`block\`\`\`, [text](url)
For mentions, use @username format.
## Users
${context.users.map(u => `@${u.username}\t${u.displayName || ''}`).join('\n')}
## Channels
${context.channels.map(c => `#${c.name}`).join('\n')}
... rest of prompt ...
`;
}
The adapter converts markdown to platform format internally:
// Inside SlackAdapter
private formatForSlack(markdown: string): string {
let text = markdown;
// Bold: **text** → *text*
text = text.replace(/\*\*(.+?)\*\*/g, '*$1*');
// Links: [text](url) → <url|text>
text = text.replace(/\[(.+?)\]\((.+?)\)/g, '<$2|$1>');
// Mentions: @username → <@U123>
text = text.replace(/@(\w+)/g, (match, username) => {
const user = this.users.find(u => u.username === username);
return user ? `<@${user.id}>` : match;
});
return text;
}
## Testing Strategy
### 1. Agent Tests (with temp Docker container)
```typescript
// test/agent.test.ts
import { MomAgent } from '../src/agent.js';
import { createTestContainer, destroyTestContainer } from './docker-utils.js';
describe('MomAgent', () => {
let containerName: string;
beforeAll(async () => {
containerName = await createTestContainer();
});
afterAll(async () => {
await destroyTestContainer(containerName);
});
it('responds to user message', async () => {
const agent = new MomAgent({
workDir: tmpDir,
sandbox: { type: 'docker', container: containerName }
});
const events: AgentSessionEvent[] = [];
await agent.handleMessage(
{
id: '1',
channelId: 'test-channel',
timestamp: new Date().toISOString(),
sender: { id: 'u1', username: 'testuser', isBot: false },
text: 'hello',
attachments: [],
isMention: true,
},
{ adapter: 'test', users: [], channels: [] },
async (event) => { events.push(event); }
);
const messageEnds = events.filter(e => e.type === 'message_end');
expect(messageEnds.length).toBeGreaterThan(0);
});
});
2. Adapter Tests (no agent)¶
// test/adapters/slack.test.ts
describe('SlackAdapter', () => {
it('converts Slack event to ChannelMessage', () => {
const slackEvent = {
type: 'message',
text: 'Hello <@U123>',
user: 'U456',
channel: 'C789',
ts: '1234567890.123456',
};
const message = SlackAdapter.parseEvent(slackEvent, userCache);
expect(message.text).toBe('Hello @someuser');
expect(message.channelId).toBe('C789');
expect(message.sender.id).toBe('U456');
});
it('converts markdown to Slack format', () => {
const slack = SlackAdapter.toSlackFormat('**bold** and [link](http://example.com)');
expect(slack).toBe('*bold* and <http://example.com|link>');
});
it('handles message_end event', async () => {
const mockClient = new MockSlackClient();
const adapter = new SlackAdapter({ client: mockClient });
await adapter.handleEvent({
type: 'message_end',
message: { role: 'assistant', content: [{ type: 'text', text: '**Hello**' }] }
}, channelContext);
// Verify Slack formatting applied
expect(mockClient.postMessage).toHaveBeenCalledWith('C123', '*Hello*');
});
});
3. Integration Tests¶
// test/integration.test.ts
describe('Mom Integration', () => {
let containerName: string;
beforeAll(async () => {
containerName = await createTestContainer();
});
afterAll(async () => {
await destroyTestContainer(containerName);
});
it('end-to-end with CLI adapter', async () => {
const agent = new MomAgent({
workDir: tmpDir,
sandbox: { type: 'docker', container: containerName }
});
const adapter = new CLIAdapter({ agent, input: mockStdin, output: mockStdout });
await adapter.start();
mockStdin.emit('data', 'Hello mom\n');
await waitFor(() => mockStdout.data.length > 0);
expect(mockStdout.data).toContain('Hello');
});
});
Migration Path¶
- Phase 1: Refactor storage (non-breaking)
- Unify log.jsonl schema (ChannelMessage format)
-
Add migration for existing Slack-format logs
-
Phase 2: Extract adapter interface (non-breaking)
- Create SlackAdapter wrapping current SlackBot
-
Agent emits events, adapter handles UI
-
Phase 3: Decouple agent (non-breaking)
- Remove Slack-specific code from agent.ts
-
Agent becomes fully platform-agnostic
-
Phase 4: Add Discord (new feature)
- Implement DiscordAdapter
- Share all storage and agent code
Decisions¶
-
Channel ID collision: Prefix with adapter name (
channels/slack-acme/C123/). -
Threads: Adapter decides. Slack uses threads, Discord can use threads or embeds.
-
Mentions: Store as-is from platform. Agent outputs
@username, adapter converts. -
Rate limiting: Each adapter handles its own.
-
Config: Single
config.jsonwith all adapter configs and tokens.
File Structure¶
packages/mom/src/
├── main.ts # CLI entry point
├── agent.ts # MomAgent
├── store.ts # ChannelStore
├── context.ts # Session management
├── sandbox.ts # Sandbox execution
├── events.ts # Scheduled events
├── log.ts # Console logging
│
├── adapters/
│ ├── types.ts # PlatformAdapter, ChannelMessage interfaces
│ ├── slack.ts # SlackAdapter
│ ├── discord.ts # DiscordAdapter
│ └── cli.ts # CLIAdapter (for testing)
│
└── tools/
├── index.ts
├── bash.ts
├── read.ts
├── write.ts
├── edit.ts
└── attach.ts
Custom Tools (Host-Side Execution)¶
Mom runs bash commands inside a sandbox (Docker container), but sometimes you need tools that run on the host machine (e.g., accessing host APIs, credentials, or services that can't run in the container).
Architecture¶
┌─────────────────────────────────────────────────────────────────────────┐
│ Host Machine │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Mom Process (Node.js) │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────────┐│ │
│ │ │ CustomTool │ │ CustomTool │ │ invoke_tool (AgentTool) ││ │
│ │ │ gmail │ │ calendar │ │ - receives tool name + args ││ │
│ │ │ (loaded via │ │ (loaded via │ │ - dispatches to custom tool ││ │
│ │ │ jiti) │ │ jiti) │ │ - returns result to agent ││ │
│ │ └─────────────┘ └─────────────┘ └─────────────────────────────┘│ │
│ │ ▲ │ │ │
│ │ │ execute() │ invoke_tool() │ │
│ │ │ ▼ │ │
│ │ ┌───────────────────────────────────────────────────────────────┐│ │
│ │ │ MomAgent ││ │
│ │ │ - System prompt describes all custom tools ││ │
│ │ │ - Has invoke_tool as one of its tools ││ │
│ │ │ - Mom calls invoke_tool("gmail", {action: "search", ...}) ││ │
│ │ └───────────────────────────────────────────────────────────────┘│ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │ │
│ │ bash tool (Docker exec) │
│ ▼ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Docker Container (Sandbox) │ │
│ │ - Mom's bash commands run here │ │
│ │ - Isolated from host (except mounted workspace) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
Custom Tool Interface¶
// data/tools/gmail/index.ts
import type { MomCustomTool, ToolAPI } from "@mariozechner/pi-mom";
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
const tool: MomCustomTool = {
name: "gmail",
description: "Search, read, and send emails via Gmail",
parameters: Type.Object({
action: StringEnum(["search", "read", "send"]),
query: Type.Optional(Type.String({ description: "Search query" })),
messageId: Type.Optional(Type.String({ description: "Message ID to read" })),
to: Type.Optional(Type.String({ description: "Recipient email" })),
subject: Type.Optional(Type.String({ description: "Email subject" })),
body: Type.Optional(Type.String({ description: "Email body" })),
}),
async execute(toolCallId, params, signal) {
switch (params.action) {
case "search":
const results = await searchEmails(params.query);
return {
content: [{ type: "text", text: formatSearchResults(results) }],
details: { count: results.length },
};
case "read":
const email = await readEmail(params.messageId);
return {
content: [{ type: "text", text: email.body }],
details: { from: email.from, subject: email.subject },
};
case "send":
await sendEmail(params.to, params.subject, params.body);
return {
content: [{ type: "text", text: `Email sent to ${params.to}` }],
details: { sent: true },
};
}
},
};
export default tool;
MomCustomTool Type¶
import type { TSchema, Static } from "@sinclair/typebox";
export interface MomToolResult<TDetails = any> {
content: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>;
details?: TDetails;
}
export interface MomCustomTool<TParams extends TSchema = TSchema, TDetails = any> {
/** Tool name (must be unique) */
name: string;
/** Human-readable description for system prompt */
description: string;
/** TypeBox schema for parameters */
parameters: TParams;
/** Execute the tool */
execute: (
toolCallId: string,
params: Static<TParams>,
signal?: AbortSignal,
) => Promise<MomToolResult<TDetails>>;
/** Optional: called when mom starts (for initialization) */
onStart?: () => Promise<void>;
/** Optional: called when mom stops (for cleanup) */
onStop?: () => Promise<void>;
}
/** Factory function for tools that need async initialization */
export type MomCustomToolFactory = (api: ToolAPI) => MomCustomTool | Promise<MomCustomTool>;
export interface ToolAPI {
/** Path to mom's data directory */
dataDir: string;
/** Execute a command on the host (not in sandbox) */
exec: (command: string, args: string[], options?: ExecOptions) => Promise<ExecResult>;
/** Read a file from the data directory */
readFile: (path: string) => Promise<string>;
/** Write a file to the data directory */
writeFile: (path: string, content: string) => Promise<void>;
}
Tool Discovery and Loading¶
Tools are discovered from:
1. data/tools/**/index.ts (workspace-local, recursive)
2. ~/.pi/mom/tools/**/index.ts (global, recursive)
// loader.ts
import { createJiti } from "jiti";
interface LoadedTool {
path: string;
tool: MomCustomTool;
}
async function loadCustomTools(dataDir: string): Promise<LoadedTool[]> {
const tools: LoadedTool[] = [];
const jiti = createJiti(import.meta.url, { alias: getAliases() });
// Discover tool directories
const toolDirs = [
path.join(dataDir, "tools"),
path.join(os.homedir(), ".pi", "mom", "tools"),
];
for (const dir of toolDirs) {
if (!fs.existsSync(dir)) continue;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const indexPath = path.join(dir, entry.name, "index.ts");
if (!fs.existsSync(indexPath)) continue;
try {
const module = await jiti.import(indexPath, { default: true });
const toolOrFactory = module as MomCustomTool | MomCustomToolFactory;
const tool = typeof toolOrFactory === "function"
? await toolOrFactory(createToolAPI(dataDir))
: toolOrFactory;
tools.push({ path: indexPath, tool });
} catch (err) {
console.error(`Failed to load tool from ${indexPath}:`, err);
}
}
}
return tools;
}
The invoke_tool Agent Tool¶
Mom has a single invoke_tool tool that dispatches to custom tools:
import { Type } from "@sinclair/typebox";
function createInvokeToolTool(loadedTools: LoadedTool[]): AgentTool {
const toolMap = new Map(loadedTools.map(t => [t.tool.name, t.tool]));
return {
name: "invoke_tool",
label: "Invoke Tool",
description: "Invoke a custom tool running on the host machine",
parameters: Type.Object({
tool: Type.String({ description: "Name of the tool to invoke" }),
args: Type.Any({ description: "Arguments to pass to the tool (tool-specific)" }),
}),
async execute(toolCallId, params, signal) {
const tool = toolMap.get(params.tool);
if (!tool) {
return {
content: [{ type: "text", text: `Unknown tool: ${params.tool}` }],
details: { error: true },
isError: true,
};
}
try {
// Validate args against tool's schema
// (TypeBox validation here)
const result = await tool.execute(toolCallId, params.args, signal);
return {
content: result.content,
details: { tool: params.tool, ...result.details },
};
} catch (err) {
return {
content: [{ type: "text", text: `Tool error: ${err.message}` }],
details: { error: true, tool: params.tool },
isError: true,
};
}
},
};
}
System Prompt Integration¶
Custom tools are described in the system prompt so mom knows what's available:
function formatCustomToolsForPrompt(tools: LoadedTool[]): string {
if (tools.length === 0) return "";
let section = `\n## Custom Tools (Host-Side)
These tools run on the host machine (not in your sandbox). Use the \`invoke_tool\` tool to call them.
`;
for (const { tool } of tools) {
section += `### ${tool.name}
${tool.description}
**Parameters:**
\`\`\`json
${JSON.stringify(schemaToSimpleJson(tool.parameters), null, 2)}
\`\`\`
**Example:**
\`\`\`
invoke_tool(tool: "${tool.name}", args: { ... })
\`\`\`
`;
}
return section;
}
// Convert TypeBox schema to simple JSON for display
function schemaToSimpleJson(schema: TSchema): object {
// Simplified schema representation for the LLM
// ...
}
Example: Gmail Tool¶
// data/tools/gmail/index.ts
import type { MomCustomTool, ToolAPI } from "@mariozechner/pi-mom";
import { Type } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import Imap from "imap";
import nodemailer from "nodemailer";
export default async function(api: ToolAPI): Promise<MomCustomTool> {
// Load credentials from data directory
const credsPath = path.join(api.dataDir, "tools", "gmail", "credentials.json");
const creds = JSON.parse(await api.readFile(credsPath));
return {
name: "gmail",
description: "Search, read, and send emails via Gmail. Requires credentials.json in the tool directory.",
parameters: Type.Object({
action: StringEnum(["search", "read", "send", "list"]),
// ... other params
}),
async execute(toolCallId, params, signal) {
// Implementation using imap/nodemailer
},
};
}
Security Considerations¶
- Tools run on host: Custom tools have full host access. Only install trusted tools.
- Credential storage: Tools should store credentials in the data directory, not in code.
- Sandbox separation: The sandbox (Docker) can't access host tools directly. Only mom's invoke_tool can call them.
Loading¶
Tools are loaded via jiti. They can import any 3rd party dependencies (install in the tool directory). Imports of @mariozechner/pi-ai and @mariozechner/pi-mom are aliased to the running mom bundle.
Live reload: In dev mode, tools are watched and reloaded on change. No restart needed.
Events System¶
Scheduled wake-ups via JSON files in workspace/events/.
Format¶
{"type": "one-shot", "channelId": "slack-acme/C123ABC", "text": "Reminder", "at": "2025-12-15T09:00:00+01:00"}
Channel ID is qualified with adapter name so the event watcher knows which adapter to use.
Running¶
Reads config.json, starts all adapters defined there.
The shared workspace allows: - Shared MEMORY.md (global knowledge) - Shared skills - Events can target any platform - Per-channel data is still isolated by channel ID
Summary¶
The key insight is separation of concerns:
- Storage: Unified schema, messages stored as-is from platform
- Agent: Doesn't know about Slack/Discord, just processes messages and emits events
- Adapters: Handle platform-specific connection, formatting, and message splitting
- Progress Rendering: Each adapter decides how to display tool progress and results
This allows:
- Testing agent without any platform
- Testing adapters without agent
- Adding new platforms by implementing PlatformAdapter
- Sharing all storage, context management, and agent logic
- Rich UI on platforms that support it (embeds, buttons)
- Graceful degradation on simpler platforms (plain text)