docs: v2 architecture design — session DB, channel adapters, agent provider
Three documents covering the complete v2 architecture: - v2-architecture-draft.md: Core design (per-session SQLite as sole IO, two-level DB, entity model, channel adapters with Chat SDK bridge, container lifecycle, message flow, interactive operations, routing, flexibility model with PR Factory example) - v2-api-details.md: Channel adapter interface definitions, Chat SDK bridge implementation, native channel example, message content format examples, host delivery logic - v2-agent-runner-details.md: AgentProvider interface (stream-in/out), provider implementations (Claude, Codex, OpenCode), poll loop, MCP tool definitions, message formatting, media handling, container startup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
360
docs/v2-api-details.md
Normal file
360
docs/v2-api-details.md
Normal file
@@ -0,0 +1,360 @@
|
||||
# NanoClaw v2 API Details
|
||||
|
||||
Implementation-level details for the v2 architecture. See [v2-architecture-draft.md](v2-architecture-draft.md) for the high-level design.
|
||||
|
||||
## Channel Adapter Interface
|
||||
|
||||
### NanoClaw Channel Interface (v2)
|
||||
|
||||
```typescript
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### Chat SDK Bridge
|
||||
|
||||
Wraps a Chat SDK adapter + Chat instance to conform to the NanoClaw ChannelAdapter interface.
|
||||
|
||||
```typescript
|
||||
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 NanoClaw Channel (no Chat SDK)
|
||||
|
||||
Native channels implement the ChannelAdapter interface directly. Example structure for WhatsApp/Baileys:
|
||||
|
||||
```typescript
|
||||
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; },
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Session DB Schema Details
|
||||
|
||||
### messages_in content examples
|
||||
|
||||
**`chat`** — simple NanoClaw format:
|
||||
```json
|
||||
{
|
||||
"sender": "John",
|
||||
"senderId": "user123",
|
||||
"text": "Check this PR",
|
||||
"attachments": [{ "type": "image", "url": "https://signed-url..." }],
|
||||
"isFromMe": false
|
||||
}
|
||||
```
|
||||
|
||||
**`chat-sdk`** — full Chat SDK `SerializedMessage`:
|
||||
```json
|
||||
{
|
||||
"_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):
|
||||
```json
|
||||
{
|
||||
"sender": "John",
|
||||
"senderId": "user123",
|
||||
"text": "Yes",
|
||||
"questionId": "q-123",
|
||||
"selectedOption": "Yes",
|
||||
"isFromMe": false
|
||||
}
|
||||
```
|
||||
|
||||
### messages_out content examples
|
||||
|
||||
**Normal chat message:**
|
||||
```json
|
||||
{ "text": "LGTM, merging now" }
|
||||
```
|
||||
|
||||
**Chat SDK markdown:**
|
||||
```json
|
||||
{ "markdown": "## Review Summary\n**Status**: Approved\n\nNo issues found." }
|
||||
```
|
||||
|
||||
**Card:**
|
||||
```json
|
||||
{
|
||||
"card": {
|
||||
"type": "card",
|
||||
"title": "Deployment Approval",
|
||||
"children": [
|
||||
{ "type": "text", "content": "Deploy v2.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 v2.1.0 to production? [Approve] [Reject]"
|
||||
}
|
||||
```
|
||||
|
||||
**Ask user question:**
|
||||
```json
|
||||
{
|
||||
"operation": "ask_question",
|
||||
"questionId": "q-123",
|
||||
"question": "How should we handle the failing test?",
|
||||
"options": ["Skip it", "Fix and retry", "Abort deployment"]
|
||||
}
|
||||
```
|
||||
|
||||
**Edit message:**
|
||||
```json
|
||||
{ "operation": "edit", "messageId": "3", "text": "Updated: LGTM with minor comments on line 42" }
|
||||
```
|
||||
|
||||
**Reaction:**
|
||||
```json
|
||||
{ "operation": "reaction", "messageId": "5", "emoji": "thumbs_up" }
|
||||
```
|
||||
|
||||
**System action:**
|
||||
```json
|
||||
{ "action": "reset_session", "payload": { "session_id": "sess-123", "reason": "Skills updated" } }
|
||||
```
|
||||
|
||||
## Host Delivery Logic
|
||||
|
||||
The host reads messages_out and dispatches based on `kind` and `operation`:
|
||||
|
||||
```typescript
|
||||
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).
|
||||
Reference in New Issue
Block a user