Files
stack/docs/research/01-chat-orchestration-research.md
2026-03-01 16:08:40 -06:00

21 KiB

Chat Interface + Task Orchestration Research Report

Date: 2026-03-01
Focus: Analysis of Mission Control and Clawtrol for Mosaic Stack feature development
Goal: Extract actionable design patterns for chat, task dispatch, and live event feeds


Executive Summary

Both Mission Control and Clawtrol are OpenClaw-compatible dashboards with complementary strengths:

Feature Mission Control Clawtrol Mosaic Stack Gap
Chat with agents No direct chat Full session chat + send HIGH - Stub exists, not wired
Task dispatch AI planning + Kanban Simple Kanban Medium - Kanban exists
Live events SSE-based feed Polling only Medium - SSE polling exists
Session viewer No Full transcript view HIGH - Missing
Agent management Auto-create agents Basic list Medium

Top 3 Quick Wins for Mosaic Stack:

  1. Session chat interface (< 4 hours) - Wire existing chat stub to OpenClaw API
  2. Session list view (< 2 hours) - Read sessions.json + .jsonl transcripts
  3. Task card planning indicator (< 1 hour) - Add purple pulse animation

1. Chat Interface Analysis

Clawtrol Sessions Module (Best Reference)

File: src/components/modules/SessionsModule/index.tsx

Key Architecture:

// Session list fetched from OpenClaw
const res = await fetch('/api/sessions');
const data = await res.json();
setSessions(data.sessions || []);

// Session detail with message history
const res = await fetch(`/api/sessions/${encodeURIComponent(session.key)}?limit=50`);
const data = await res.json();
setChatMessages(data.messages || []);

// Send message to session (via Telegram or direct)
await fetch('/api/sessions/send', {
  method: 'POST',
  body: JSON.stringify({ sessionKey: selectedSession.key, message: msg }),
});

UI Pattern - Two-Column Chat Layout:

// Session list view
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-3">
  {sessions.map(session => (
    <div onClick={() => openSessionChat(session)}>
      {/* Activity indicator */}
      <div className="w-2 h-2 rounded-full" 
           style={{ background: activityColor, boxShadow: '0 0 6px ...' }} />
      
      {/* Session metadata */}
      <span>{session.messageCount} msgs · {session.totalTokens}k tokens</span>
      <span>${session.estimatedCost.toFixed(2)}</span>
      
      {/* Last message preview */}
      <div className="truncate">
        {session.lastMessages[0]?.text?.slice(0, 100)}
      </div>
    </div>
  ))}
</div>

Chat View Pattern:

// Messages container with auto-scroll
<div className="flex-1 overflow-auto p-4 space-y-3">
  {chatMessages.map(msg => (
    <div className={msg.role === 'user' ? 'justify-end' : 'justify-start'}>
      <div className="max-w-[85%] rounded-lg px-3 py-2">
        {/* Role badge */}
        <span className="text-[9px] uppercase">
          {msg.role === 'user' ? 'you' : 'assistant'}
        </span>
        
        {/* Markdown content */}
        <div>{renderMarkdown(msg.text)}</div>
      </div>
    </div>
  ))}
  <div ref={chatEndRef} /> {/* Auto-scroll anchor */}
</div>

// Input with Enter to send
<input onKeyDown={e => e.key === 'Enter' && sendChatMessage()} />

Session API Pattern (/api/sessions/route.ts):

// Priority: CLI > Index file > Direct file scan
const SESSIONS_INDEX = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions', 'sessions.json');
const SESSIONS_DIR = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions');

// Read sessions from index
const sessionsMap = JSON.parse(await readFile(SESSIONS_INDEX, 'utf-8'));

// Enrich with message count and last messages
for (const session of sessions) {
  const [msgs, count] = await Promise.all([
    getLastMessages(sessionFile, 3),  // Last 3 messages
    getMessageCount(sessionFile),      // Total count
  ]);
}

// Parse JSONL for messages
function getLastMessages(sessionFile: string, count: number) {
  const lines = data.trim().split('\n').filter(Boolean);
  for (let i = lines.length - 1; i >= 0 && messages.length < count; i--) {
    const parsed = JSON.parse(lines[i]);
    if (parsed.type === 'message' && parsed.message) {
      messages.unshift({
        role: parsed.message.role,
        text: extractTextFromContent(parsed.message.content),
        timestamp: parsed.timestamp,
      });
    }
  }
}

Message Send Pattern (/api/sessions/send/route.ts):

// Parse session key to determine target
function parseSessionKey(key: string): { chatId: string; topicId?: string } | null {
  // agent:main:main → DM to owner
  if (key === 'agent:main:main') {
    return { chatId: await getDefaultChatId() };
  }
  
  // agent:main:telegram:group:<id>:topic:<id>
  const topicMatch = key.match(/:group:(-?\d+):topic:(\d+)$/);
  if (topicMatch) {
    return { chatId: topicMatch[1], topicId: topicMatch[2] };
  }
}

// Send via Telegram Bot API (or could use OpenClaw chat.send)
const res = await fetch(`https://api.telegram.org/bot${botToken}/sendMessage`, {
  method: 'POST',
  body: JSON.stringify({ chat_id: target.chatId, text: message }),
});

Key Takeaways for Mosaic Stack

  1. Session key format: agent:main:telegram:group:<id>:topic:<id> or agent:main:main
  2. JSONL parsing: Read from ~/.openclaw/agents/main/sessions/<session-id>.jsonl
  3. Cost estimation:
    const isOpus = modelName.includes('opus');
    const inputRate = isOpus ? 15 : 3;
    const outputRate = isOpus ? 75 : 15;
    const cost = (inputTokens / 1_000_000 * inputRate) + (outputTokens / 1_000_000 * outputRate);
    
  4. Activity color logic:
    if (lastActivity > hourAgo) return 'green';  // Active
    if (lastActivity > dayAgo) return 'yellow';  // Recent
    return 'dim';                                 // Stale
    

2. Task/Agent Dispatch Flow (Mission Control)

AI Planning UX Pattern

The Flow:

CREATE → PLAN (AI Q&A) → ASSIGN (Auto-agent) → EXECUTE → DELIVER

Status Columns:

PLANNING → INBOX → ASSIGNED → IN PROGRESS → TESTING → REVIEW → DONE

PlanningTab.tsx - Core Pattern:

  1. Start Planning Button:
if (!state?.isStarted) {
  return (
    <button onClick={startPlanning} className="px-6 py-3 bg-mc-accent">
      📋 Start Planning
    </button>
  );
}
  1. Question/Answer Loop:
// Current question display
<h3>{state.currentQuestion.question}</h3>

// Multiple choice options
{state.currentQuestion.options.map(option => (
  <button
    onClick={() => setSelectedOption(option.label)}
    className={isSelected ? 'border-mc-accent bg-mc-accent/10' : 'border-mc-border'}
  >
    <span className="w-8 h-8">{option.id.toUpperCase()}</span>
    <span>{option.label}</span>
    {isSelected && <CheckCircle />}
  </button>
))}

// "Other" option with text input
{isOther && isSelected && (
  <input placeholder="Please specify..." value={otherText} />
)}
  1. Polling for AI Response:
// Poll every 2 seconds for next question
pollingIntervalRef.current = setInterval(() => {
  pollForUpdates();
}, 2000);

// 90-second timeout
pollingTimeoutRef.current = setTimeout(() => {
  setError('Taking too long to respond...');
}, 90000);
  1. Planning Complete - Spec Display:
if (state?.isComplete && state?.spec) {
  return (
    <div>
      <div className="flex items-center gap-2 text-green-400">
        <Lock /> Planning Complete
      </div>
      
      {/* Generated spec */}
      <div className="bg-mc-bg border rounded-lg p-4">
        <h3>{state.spec.title}</h3>
        <p>{state.spec.summary}</p>
        <ul>{state.spec.deliverables.map(d => <li>{d}</li>)}</ul>
        <ul>{state.spec.success_criteria.map(c => <li>{c}</li>)}</ul>
      </div>
      
      {/* Auto-created agents */}
      {state.agents.map(agent => (
        <div className="flex items-center gap-3">
          <span className="text-2xl">{agent.avatar_emoji}</span>
          <div>
            <p>{agent.name}</p>
            <p className="text-sm">{agent.role}</p>
          </div>
        </div>
      ))}
    </div>
  );
}

Planning API Pattern

POST /api/tasks/[id]/planning - Start Planning:

// Create session key
const sessionKey = `agent:main:planning:${taskId}`;

// Build planning prompt
const planningPrompt = `
PLANNING REQUEST

Task Title: ${task.title}
Task Description: ${task.description}

Generate your FIRST question. Respond with ONLY valid JSON:
{
  "question": "Your question here?",
  "options": [
    {"id": "A", "label": "First option"},
    {"id": "B", "label": "Second option"},
    {"id": "other", "label": "Other"}
  ]
}
`;

// Send to OpenClaw
await client.call('chat.send', {
  sessionKey,
  message: planningPrompt,
});

// Store in DB
UPDATE tasks SET planning_session_key = ?, planning_messages = ?, status = 'planning'

Key Insight: The AI doesn't just plan - it asks multiple-choice questions to clarify requirements. This is the "AI clarification before dispatch" pattern.

Kanban Card with Planning Indicator

// TaskCard.tsx
const isPlanning = task.status === 'planning';

<div className={isPlanning 
  ? 'border-purple-500/40 hover:border-purple-500' 
  : 'border-mc-border/50 hover:border-mc-accent/40'}>
  
  {isPlanning && (
    <div className="flex items-center gap-2 py-2 px-3 bg-purple-500/10">
      <div className="w-2 h-2 bg-purple-500 rounded-full animate-pulse" />
      <span className="text-xs text-purple-400">Continue planning</span>
    </div>
  )}
</div>

Auto-Dispatch Pattern

// When task moves from PLANNING → INBOX (planning complete)
if (shouldTriggerAutoDispatch(oldStatus, newStatus, agentId)) {
  await triggerAutoDispatch({
    taskId,
    taskTitle,
    agentId,
    agentName,
    workspaceId,
  });
}

3. Live Event Feed

Mission Control SSE Pattern

src/lib/events.ts:

// In-memory client registry
const clients = new Set<ReadableStreamDefaultController>();

export function registerClient(controller) {
  clients.add(controller);
}

export function broadcast(event: SSEEvent) {
  const data = `data: ${JSON.stringify(event)}\n\n`;
  const encoded = new TextEncoder().encode(data);
  
  for (const client of Array.from(clients)) {
    try {
      client.enqueue(encoded);
    } catch {
      clients.delete(client);
    }
  }
}

LiveFeed Component:

// Filter tabs
<div className="flex gap-1">
  {['all', 'tasks', 'agents'].map(tab => (
    <button className={filter === tab ? 'bg-mc-accent' : ''}>
      {tab}
    </button>
  ))}
</div>

// Event list with icons
{filteredEvents.map(event => (
  <div className={`p-2 rounded border-l-2 ${
    isHighlight ? 'bg-mc-bg-tertiary border-mc-accent-pink' : 'hover:bg-mc-bg-tertiary'
  }`}>
    <span>{getEventIcon(event.type)}</span>
    <p>{event.message}</p>
    <span className="text-xs">{formatDistanceToNow(event.created_at)}</span>
  </div>
))}

// Event icons
function getEventIcon(type: string) {
  switch (type) {
    case 'task_created': return '📋';
    case 'task_assigned': return '👤';
    case 'task_completed': return '✅';
    case 'message_sent': return '💬';
    case 'agent_joined': return '🎉';
  }
}

SSE vs WebSocket Trade-off

Aspect SSE (Mission Control) WebSocket (Clawtrol)
Direction Server → Client only Bidirectional
Reconnect Automatic browser handling Manual implementation
Overhead HTTP-based, lighter Full TCP connection
Use case Event feeds, notifications Real-time terminal, chat

Recommendation: Use SSE for event feeds (simpler), WebSocket for interactive terminals.


4. Session Viewer Pattern

Clawtrol Session List

// Session card with activity indicator
<div className="card-base cursor-pointer" onClick={() => openSessionChat(session)}>
  {/* Activity dot */}
  <div className="w-2 h-2 rounded-full" 
       style={{ 
         background: activityColor, 
         boxShadow: activityColor === 'green' ? '0 0 6px rgba(0,255,106,0.5)' : undefined 
       }} />
  
  {/* Session info */}
  <h3 className="truncate">{session.label}</h3>
  <div className="text-[9px]">
    {session.messageCount} msgs · {session.totalTokens}k tokens
    {session.estimatedCost > 0 && <span> · ${session.estimatedCost.toFixed(2)}</span>}
    {session.model && <span> · {session.model}</span>}
  </div>
  
  {/* Last message preview */}
  {session.lastMessages?.length > 0 && (
    <div className="mt-2 p-2 rounded bg-secondary">
      <span>{session.lastMessages[0]?.role === 'user' ? 'you: ' : 'assistant: '}</span>
      <span className="truncate">{session.lastMessages[0]?.text?.slice(0, 100)}</span>
    </div>
  ))}
</div>

Session Label Mapping

const TOPIC_NAMES: Record<string, string> = {
  '1369': '🔖 Bookmarks',
  '13': '🌴 Bali Trip',
  '14': '💰 Expenses',
  // ... user-defined topic labels
};

function getSessionLabel(key: string): string {
  if (key === 'agent:main:main') return 'Main Session (DM)';
  if (key.includes(':subagent:')) return `Subagent ${uuid.slice(0, 8)}`;
  
  // Telegram topic
  const topicMatch = key.match(/:topic:(\d+)$/);
  if (topicMatch) {
    return TOPIC_NAMES[topicMatch[1]] || `Topic ${topicMatch[1]}`;
  }
  
  return key.split(':').pop() || key;
}

5. OpenClaw Client Integration

WebSocket Client Pattern

src/lib/openclaw/client.ts:

export class OpenClawClient extends EventEmitter {
  private ws: WebSocket | null = null;
  private pendingRequests = new Map<string, PromiseHandlers>();
  private connected = false;
  private authenticated = false;

  async connect(): Promise<void> {
    // Add token to URL for auth
    const wsUrl = new URL(this.url);
    wsUrl.searchParams.set('token', this.token);
    
    this.ws = new WebSocket(wsUrl.toString());
    
    this.ws.onmessage = (event) => {
      const data = JSON.parse(event.data);
      
      // Handle challenge-response auth
      if (data.type === 'event' && data.event === 'connect.challenge') {
        const response = {
          type: 'req',
          id: crypto.randomUUID(),
          method: 'connect',
          params: {
            auth: { token: this.token },
            role: 'operator',
            scopes: ['operator.admin'],
          }
        };
        this.ws.send(JSON.stringify(response));
        return;
      }
      
      // Handle RPC responses
      if (data.type === 'res') {
        const pending = this.pendingRequests.get(data.id);
        if (pending) {
          data.ok ? pending.resolve(data.payload) : pending.reject(data.error);
        }
      }
    };
  }

  async call<T>(method: string, params?: object): Promise<T> {
    const id = crypto.randomUUID();
    const message = { type: 'req', id, method, params };
    
    return new Promise((resolve, reject) => {
      this.pendingRequests.set(id, { resolve, reject });
      this.ws.send(JSON.stringify(message));
      
      // 30s timeout
      setTimeout(() => {
        if (this.pendingRequests.has(id)) {
          this.pendingRequests.delete(id);
          reject(new Error(`Timeout: ${method}`));
        }
      }, 30000);
    });
  }

  // Convenience methods
  async listSessions() { return this.call('sessions.list'); }
  async sendMessage(sessionId: string, content: string) {
    return this.call('sessions.send', { session_id: sessionId, content });
  }
  async listAgents() { return this.call('agents.list'); }
}

Event Deduplication Pattern

// Global dedup cache (survives Next.js hot reload)
const GLOBAL_EVENT_CACHE_KEY = '__openclaw_processed_events__';
const globalProcessedEvents = globalThis[GLOBAL_EVENT_CACHE_KEY] || new Map();

// Content-based event ID
function generateEventId(data: any): string {
  const canonical = JSON.stringify({
    type: data.type,
    seq: data.seq,
    runId: data.payload?.runId,
    payloadHash: createHash('sha256').update(JSON.stringify(data.payload)).digest('hex').slice(0, 16),
  });
  return createHash('sha256').update(canonical).digest('hex').slice(0, 32);
}

// Skip duplicates
if (globalProcessedEvents.has(eventId)) return;
globalProcessedEvents.set(eventId, Date.now());

// LRU cleanup
if (globalProcessedEvents.size > MAX_EVENTS) {
  // Remove oldest entries
}

6. Feature Recommendations for Mosaic Stack

Quick Wins (< 4 hours each)

Feature Effort Impact Source
Session list page 2h HIGH Clawtrol
Session chat interface 4h HIGH Clawtrol
Planning indicator on task cards 1h MEDIUM Mission Control
Activity dots (green/yellow/dim) 30m MEDIUM Clawtrol
Token/cost display per session 1h MEDIUM Clawtrol
Event feed filter tabs 1h LOW Mission Control

Medium Effort (4-16 hours)

Feature Effort Impact Description
AI planning flow 8h HIGH Multi-choice Q&A before dispatch
OpenClaw WebSocket client 4h HIGH Real-time event streaming
Session transcript viewer 4h MEDIUM JSONL parsing + display
Auto-agent creation 8h MEDIUM Generate agents from planning spec

Architecture Recommendations

  1. Keep SSE for event feed - Simpler than WebSocket for one-way updates
  2. Use OpenClaw chat.send for messages - Don't implement Telegram API directly
  3. Store session metadata in PostgreSQL - Mirror sessions.json for joins
  4. Implement planning as a state machine - Clear states: idle → started → questioning → complete

7. Code Snippets to Reuse

Session API Route (Clawtrol-style)

// app/api/sessions/route.ts
import { readFile, readdir } from 'fs/promises';
import { join } from 'path';
import os from 'os';

const SESSIONS_DIR = join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions');

export async function GET() {
  // Try CLI first
  try {
    const { stdout } = await execAsync('openclaw sessions --json');
    return NextResponse.json({ sessions: JSON.parse(stdout).sessions, source: 'cli' });
  } catch {}
  
  // Fallback to file
  const index = await readFile(join(SESSIONS_DIR, 'sessions.json'), 'utf-8');
  const sessionsMap = JSON.parse(index);
  
  const sessions = await Promise.all(
    Object.entries(sessionsMap).map(async ([key, data]) => ({
      key,
      label: getSessionLabel(key),
      kind: getSessionKind(key),
      lastActivity: new Date(data.updatedAt).toISOString(),
      messageCount: await getMessageCount(key),
      totalTokens: data.totalTokens || 0,
      estimatedCost: calculateCost(data),
    }))
  );
  
  return NextResponse.json({ sessions, source: 'file' });
}

Activity Indicator Component

// components/ActivityIndicator.tsx
export function ActivityIndicator({ lastActivity }: { lastActivity: Date }) {
  const now = Date.now();
  const hourAgo = now - 60 * 60 * 1000;
  const dayAgo = now - 24 * 60 * 60 * 1000;
  
  const color = lastActivity.getTime() > hourAgo 
    ? 'bg-green-500' 
    : lastActivity.getTime() > dayAgo 
      ? 'bg-yellow-500' 
      : 'bg-gray-500';
  
  const glow = lastActivity.getTime() > hourAgo 
    ? 'shadow-[0_0_6px_rgba(34,197,94,0.5)]' 
    : '';
  
  return (
    <div className={`w-2 h-2 rounded-full ${color} ${glow}`} />
  );
}

Cost Estimation Utility

// lib/cost-estimation.ts
const RATES = {
  opus: { input: 15, output: 75 },
  sonnet: { input: 3, output: 15 },
  haiku: { input: 0.25, output: 1.25 },
};

export function estimateCost(model: string, inputTokens: number, outputTokens: number): number {
  const tier = model.includes('opus') ? 'opus' 
    : model.includes('sonnet') ? 'sonnet' 
    : 'haiku';
  
  const rates = RATES[tier];
  return (inputTokens / 1_000_000 * rates.input) + 
         (outputTokens / 1_000_000 * rates.output);
}

8. Summary

Best patterns to steal:

  1. Clawtrol's session chat - Clean two-panel layout with activity dots
  2. Mission Control's planning flow - Multi-choice Q&A with polling
  3. Clawtrol's JSONL parsing - Efficient reverse-iteration for last N messages
  4. Mission Control's SSE events - Simple broadcast pattern with client registry
  5. Activity color logic - Hour = green, day = yellow, older = dim

Don't copy:

  1. Telegram Bot API integration - Use OpenClaw chat.send instead
  2. File-based session index - Mosaic Stack has PostgreSQL
  3. PM2 daemon management - Use Docker/systemd

Next steps:

  1. Create /app/(dashboard)/sessions page with session list
  2. Add chat view at /app/(dashboard)/sessions/[key]
  3. Wire /api/sessions route to OpenClaw CLI or sessions.json
  4. Add ActivityIndicator component to session cards
  5. Add "Start Planning" button to task cards in Kanban