docs/api-details.md
Implementation-level details for the architecture. See architecture.md for the high-level design.
interface ChannelSetup {
// Conversation configs from central DB — passed at setup, not queried by adapter
conversations: ConversationConfig[];
// Host callbacks
onInbound(platformId: string, threadId: string | null, message: InboundMessage): void;
onMetadata(platformId: string, name?: string, isGroup?: boolean): void;
}
interface ConversationConfig {
platformId: string;
agentGroupId: string;
triggerPattern?: string; // regex string (for native channels)
requiresTrigger: boolean;
sessionMode: 'shared' | 'per-thread';
}
interface ChannelAdapter {
name: string;
channelType: string;
// Lifecycle
setup(config: ChannelSetup): Promise<void>;
teardown(): Promise<void>;
isConnected(): boolean;
// Outbound delivery
deliver(platformId: string, threadId: string | null, message: OutboundMessage): Promise<void>;
// Optional
setTyping?(platformId: string, threadId: string | null): Promise<void>;
syncConversations?(): Promise<ConversationInfo[]>;
updateConversations?(conversations: ConversationConfig[]): void;
}
// Inbound message from adapter to host
interface InboundMessage {
id: string;
kind: 'chat' | 'chat-sdk';
content: unknown; // JSON blob — NanoClaw chat format or Chat SDK SerializedMessage
timestamp: string;
}
// Outbound message from host to adapter
interface OutboundMessage {
kind: 'chat' | 'chat-sdk';
content: unknown; // JSON blob — matches the kind
}
Wraps a Chat SDK adapter + Chat instance to conform to the NanoClaw ChannelAdapter interface. Trunk ships the bridge and the channel registry only — platform-specific Chat SDK adapters (Discord, Slack, Telegram, etc.) and native adapters (WhatsApp/Baileys) are installed by the /add-<channel> skills from the channels branch.
function createChatSdkBridge(
adapter: Adapter,
chatConfig: { concurrency?: ConcurrencyStrategy }
): ChannelAdapter {
let chat: Chat;
let hostCallbacks: ChannelSetup;
return {
name: adapter.name,
channelType: adapter.name,
async setup(config) {
hostCallbacks = config;
chat = new Chat({
adapters: { [adapter.name]: adapter },
state: new SqliteStateAdapter(),
concurrency: chatConfig.concurrency ?? 'concurrent',
});
// Subscribe registered conversations
for (const conv of config.conversations) {
if (conv.agentGroupId) {
await chat.state.subscribe(conv.platformId);
}
}
// Subscribed threads → forward all messages
chat.onSubscribedMessage(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
config.onInbound(channelId, thread.id, {
id: message.id,
kind: 'chat-sdk',
content: message.toJSON(),
timestamp: message.metadata.dateSent.toISOString(),
});
});
// @mention in unsubscribed thread → discovery
chat.onNewMention(async (thread, message) => {
const channelId = adapter.channelIdFromThreadId(thread.id);
config.onInbound(channelId, thread.id, {
id: message.id,
kind: 'chat-sdk',
content: message.toJSON(),
timestamp: message.metadata.dateSent.toISOString(),
});
// Subscribe so future messages in this thread are received
await thread.subscribe();
});
// DMs → always forward
chat.onDirectMessage(async (thread, message) => {
config.onInbound(thread.id, null, {
id: message.id,
kind: 'chat-sdk',
content: message.toJSON(),
timestamp: message.metadata.dateSent.toISOString(),
});
await thread.subscribe();
});
await chat.initialize();
},
async deliver(platformId, threadId, message) {
const tid = threadId ?? platformId;
if (message.kind === 'chat-sdk') {
const content = message.content as Record<string, unknown>;
if (content.operation === 'edit') {
await adapter.editMessage(tid, content.messageId as string,
{ markdown: content.text as string });
} else if (content.operation === 'reaction') {
await adapter.addReaction(tid, content.messageId as string,
content.emoji as string);
} else {
await adapter.postMessage(tid, content as AdapterPostableMessage);
}
} else {
const content = message.content as { text: string };
await adapter.postMessage(tid, { markdown: content.text });
}
},
async setTyping(platformId, threadId) {
await adapter.startTyping(threadId ?? platformId);
},
async teardown() {
await chat.shutdown();
},
isConnected() { return true; },
updateConversations(conversations) {
// Subscribe new conversations, could unsubscribe removed ones
for (const conv of conversations) {
if (conv.agentGroupId) {
chat.state.subscribe(conv.platformId);
}
}
},
};
}
Native channels implement the ChannelAdapter interface directly. The WhatsApp/Baileys adapter is the canonical example — it ships via the /add-whatsapp skill, not in trunk:
function createWhatsAppChannel(): ChannelAdapter {
let socket: WASocket;
let config: ChannelSetup;
return {
name: 'whatsapp',
channelType: 'whatsapp',
async setup(setup) {
config = setup;
socket = await connectBaileys();
socket.on('messages.upsert', (event) => {
for (const msg of event.messages) {
const jid = msg.key.remoteJid;
const conv = config.conversations.find(c => c.platformId === jid);
// Trigger check (native — adapter does this, not host)
if (conv?.requiresTrigger && conv.triggerPattern) {
if (!new RegExp(conv.triggerPattern).test(msg.message?.conversation || '')) {
return; // Doesn't match trigger
}
}
config.onInbound(jid, null, {
id: msg.key.id,
kind: 'chat',
content: {
sender: msg.pushName || msg.key.participant,
senderId: msg.key.participant || msg.key.remoteJid,
text: msg.message?.conversation || '',
attachments: [],
isFromMe: msg.key.fromMe,
},
timestamp: new Date(msg.messageTimestamp * 1000).toISOString(),
});
}
});
},
async deliver(platformId, threadId, message) {
const content = message.content as { text: string };
await socket.sendMessage(platformId, { text: content.text });
},
async setTyping(platformId) {
await socket.sendPresenceUpdate('composing', platformId);
},
async teardown() {
await socket.logout();
},
isConnected() { return !!socket; },
};
}
chat — simple NanoClaw format:
{
"sender": "John",
"senderId": "user123",
"text": "Check this PR",
"attachments": [{ "type": "image", "url": "https://signed-url..." }],
"isFromMe": false
}
chat-sdk — full Chat SDK SerializedMessage:
{
"_type": "chat:Message",
"id": "msg-1",
"threadId": "slack:C123:1234.5678",
"text": "Check this PR",
"formatted": { "type": "root", "children": [...] },
"author": { "userId": "U123", "userName": "john", "fullName": "John", "isBot": false, "isMe": false },
"metadata": { "dateSent": "2024-01-01T00:00:00Z", "edited": false },
"attachments": [{ "type": "image", "url": "https://...", "name": "screenshot.png" }],
"isMention": true,
"links": []
}
Question response (from user clicking an interactive card):
{
"sender": "John",
"senderId": "user123",
"text": "Yes",
"questionId": "q-123",
"selectedOption": "Yes",
"isFromMe": false
}
Normal chat message:
{ "text": "LGTM, merging now" }
Chat SDK markdown:
{ "markdown": "## Review Summary\n**Status**: Approved\n\nNo issues found." }
Card:
{
"card": {
"type": "card",
"title": "Deployment Approval",
"children": [
{ "type": "text", "content": "Deploy 2.1.0 to production?" },
{ "type": "actions", "children": [
{ "type": "button", "id": "approve", "label": "Approve", "style": "primary" },
{ "type": "button", "id": "reject", "label": "Reject", "style": "danger" }
]}
]
},
"fallbackText": "Deployment Approval: Deploy 2.1.0 to production? [Approve] [Reject]"
}
Ask user question:
{
"operation": "ask_question",
"questionId": "q-123",
"title": "Failing Test",
"question": "How should we handle the failing test?",
"options": [
"Skip it",
{ "label": "Fix and retry", "selectedLabel": "✅ Fixing", "value": "fix" },
{ "label": "Abort deployment", "selectedLabel": "❌ Aborted", "value": "abort" }
]
}
Edit message:
{ "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments on line 42" }
Reaction:
{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" }
System action:
{ "action": "reset_session", "payload": { "session_id": "sess-123", "reason": "Skills updated" } }
The host reads messages_out and dispatches based on kind and operation:
async function deliverMessage(row: MessagesOutRow, adapter: ChannelAdapter) {
const content = JSON.parse(row.content);
// System actions — host handles internally
if (row.kind === 'system') {
await handleSystemAction(content);
return;
}
// Agent-to-agent — write to target session DB
if (isAgentDestination(row)) {
await writeToAgentSession(row);
return;
}
// Channel delivery — delegate to adapter
await adapter.deliver(row.platform_id, row.thread_id, {
kind: row.kind,
content,
});
}
The adapter's deliver() method handles operation dispatch internally (post vs edit vs reaction).