React Chat App using Saiki
When you run saiki --mode web
, you get a powerful backend server with REST APIs and WebSocket support. Let's build a React chat application step by step, introducing each API capability as we go.
Available Saiki Server APIs
When Saiki runs in web mode, it provides these endpoints:
POST /api/message-sync
- Send message and get complete responsePOST /api/message
- Send message asynchronously (use WebSocket for response)POST /api/reset
- Reset conversation historyPOST /api/connect-server
- Dynamically add new MCP serversGET /api/mcp/servers
- List connected serversGET /api/mcp/servers/:id/tools
- List tools for a serverPOST /api/mcp/servers/:id/tools/:tool/execute
- Execute specific tools- WebSocket at
/
- Real-time streaming responses
Let's start simple and build up our React app layer by layer.
Layer 1: Basic Synchronous Chat
Start with the simplest possible chat interface using the synchronous API:
// components/BasicChat.tsx
import React, { useState } from 'react';
interface Message {
id: string;
content: string;
sender: 'user' | 'agent';
timestamp: Date;
}
export const BasicChat: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [currentInput, setCurrentInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const sendMessage = async () => {
if (!currentInput.trim()) return;
// Add user message
const userMessage: Message = {
id: Date.now().toString(),
content: currentInput,
sender: 'user',
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
setCurrentInput('');
setIsLoading(true);
try {
// Call Saiki's synchronous API
const response = await fetch('http://localhost:3001/api/message-sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: currentInput })
});
if (response.ok) {
const result = await response.json();
// Add agent response
const agentMessage: Message = {
id: (Date.now() + 1).toString(),
content: result.response,
sender: 'agent',
timestamp: new Date()
};
setMessages(prev => [...prev, agentMessage]);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Error sending message:', error);
// Add error message
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
content: `Error: ${error.message}`,
sender: 'agent',
timestamp: new Date()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
{/* Header */}
<div className="mb-4 p-4 bg-blue-50 rounded">
<h1 className="text-2xl font-bold">Saiki Chat - Basic Version</h1>
<p className="text-sm text-gray-600">Using synchronous API</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map(message => (
<div
key={message.id}
className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
message.sender === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800'
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
<div className="text-xs opacity-70 mt-1">
{message.timestamp.toLocaleTimeString()}
</div>
</div>
</div>
))}
{isLoading && (
<div className="flex justify-start">
<div className="bg-gray-200 text-gray-800 px-4 py-2 rounded-lg animate-pulse">
🤔 Thinking...
</div>
</div>
)}
</div>
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={currentInput}
onChange={(e) => setCurrentInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && !isLoading && sendMessage()}
placeholder="Type your message..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={isLoading}
/>
<button
onClick={sendMessage}
disabled={isLoading || !currentInput.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
{isLoading ? '...' : 'Send'}
</button>
</div>
</div>
);
};
What we've learned:
- Basic API communication with Saiki
- Simple request/response pattern
- Error handling basics
Layer 2: Add Real-time Streaming
Now let's add WebSocket support for real-time streaming responses:
// components/StreamingChat.tsx
import React, { useState, useEffect, useRef } from 'react';
interface Message {
id: string;
content: string;
sender: 'user' | 'agent';
timestamp: Date;
isStreaming?: boolean;
}
export const StreamingChat: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [currentInput, setCurrentInput] = useState('');
const [isConnected, setIsConnected] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
// WebSocket connection
useEffect(() => {
const connectWebSocket = () => {
const ws = new WebSocket('ws://localhost:3001/');
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
console.log('🟢 Connected to Saiki');
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleSaikiEvent(data);
};
ws.onclose = () => {
setIsConnected(false);
console.log('🔴 Disconnected from Saiki');
// Auto-reconnect after 3 seconds
setTimeout(connectWebSocket, 3000);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
};
connectWebSocket();
return () => {
wsRef.current?.close();
};
}, []);
const handleSaikiEvent = (data: any) => {
switch (data.event) {
case 'thinking':
// Agent started thinking
setMessages(prev => [
...prev,
{
id: Date.now().toString(),
content: '🤔 Thinking...',
sender: 'agent',
timestamp: new Date(),
isStreaming: true
}
]);
break;
case 'chunk':
// Streaming content chunk
setMessages(prev => {
const newMessages = [...prev];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.sender === 'agent' && lastMessage.isStreaming) {
// Replace "Thinking..." with actual content or append to existing content
if (lastMessage.content === '🤔 Thinking...') {
lastMessage.content = data.data.content;
} else {
lastMessage.content += data.data.content;
}
} else {
// Create new streaming message
newMessages.push({
id: Date.now().toString(),
content: data.data.content,
sender: 'agent',
timestamp: new Date(),
isStreaming: true
});
}
return newMessages;
});
break;
case 'response':
// Streaming complete
setMessages(prev => {
const newMessages = [...prev];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.sender === 'agent' && lastMessage.isStreaming) {
lastMessage.isStreaming = false;
lastMessage.content = data.data.content;
}
return newMessages;
});
break;
case 'error':
setMessages(prev => [
...prev,
{
id: Date.now().toString(),
content: `❌ Error: ${data.data.message}`,
sender: 'agent',
timestamp: new Date()
}
]);
break;
}
};
const sendMessage = async () => {
if (!currentInput.trim() || !isConnected) return;
// Add user message
const userMessage: Message = {
id: Date.now().toString(),
content: currentInput,
sender: 'user',
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
// Send via WebSocket for streaming response
wsRef.current?.send(JSON.stringify({
type: 'message',
content: currentInput
}));
setCurrentInput('');
};
return (
<div className="flex flex-col h-screen max-w-2xl mx-auto p-4">
{/* Header */}
<div className="mb-4 p-4 bg-blue-50 rounded">
<h1 className="text-2xl font-bold">Saiki Chat - Streaming Version</h1>
<div className="flex items-center gap-2">
<span className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></span>
<span className="text-sm text-gray-600">
{isConnected ? 'Connected to Saiki' : 'Connecting...'}
</span>
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map(message => (
<div
key={message.id}
className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
message.sender === 'user'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-800'
} ${message.isStreaming ? 'animate-pulse border-2 border-blue-300' : ''}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
<div className="text-xs opacity-70 mt-1">
{message.timestamp.toLocaleTimeString()}
{message.isStreaming && ' • Streaming...'}
</div>
</div>
</div>
))}
</div>
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={currentInput}
onChange={(e) => setCurrentInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type your message..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!isConnected}
/>
<button
onClick={sendMessage}
disabled={!isConnected || !currentInput.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
};
What we've added:
- WebSocket connection management
- Real-time streaming responses
- Connection status indicator
- Auto-reconnection logic
Layer 3: Add Server Management
Now let's add the ability to see and manage MCP servers:
// components/ServerManagementChat.tsx
import React, { useState, useEffect, useRef } from 'react';
interface Message {
id: string;
content: string;
sender: 'user' | 'agent';
timestamp: Date;
isStreaming?: boolean;
}
interface Server {
id: string;
name: string;
status: string;
}
export const ServerManagementChat: React.FC = () => {
const [messages, setMessages] = useState<Message[]>([]);
const [currentInput, setCurrentInput] = useState('');
const [isConnected, setIsConnected] = useState(false);
const [servers, setServers] = useState<Server[]>([]);
const [showServerPanel, setShowServerPanel] = useState(false);
const wsRef = useRef<WebSocket | null>(null);
// WebSocket connection (same as Layer 2)
useEffect(() => {
const connectWebSocket = () => {
const ws = new WebSocket('ws://localhost:3001/');
wsRef.current = ws;
ws.onopen = () => {
setIsConnected(true);
fetchServers(); // Fetch servers when connected
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
handleSaikiEvent(data);
};
ws.onclose = () => {
setIsConnected(false);
setTimeout(connectWebSocket, 3000);
};
};
connectWebSocket();
return () => wsRef.current?.close();
}, []);
// Fetch available servers
const fetchServers = async () => {
try {
const response = await fetch('http://localhost:3001/api/mcp/servers');
if (response.ok) {
const data = await response.json();
setServers(data.servers);
}
} catch (error) {
console.error('Failed to fetch servers:', error);
}
};
// Add a new server
const addServer = async () => {
const name = prompt('Server name (e.g., "my-tool"):');
const command = prompt('Command (e.g., "npx"):');
const argsInput = prompt('Arguments (comma-separated, e.g., "-y, @company/tool-server"):');
if (!name || !command) return;
const args = argsInput ? argsInput.split(',').map(s => s.trim()) : [];
try {
const response = await fetch('http://localhost:3001/api/connect-server', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
config: {
type: 'stdio',
command,
args
}
})
});
if (response.ok) {
await fetchServers(); // Refresh server list
alert(`✅ Successfully connected ${name}!`);
} else {
const error = await response.text();
alert(`❌ Failed to connect server: ${error}`);
}
} catch (error) {
console.error('Error connecting server:', error);
alert(`❌ Error: ${error.message}`);
}
};
const handleSaikiEvent = (data: any) => {
switch (data.event) {
case 'thinking':
setMessages(prev => [...prev, {
id: Date.now().toString(),
content: '🤔 Thinking...',
sender: 'agent',
timestamp: new Date(),
isStreaming: true
}]);
break;
case 'chunk':
setMessages(prev => {
const newMessages = [...prev];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.sender === 'agent' && lastMessage.isStreaming) {
if (lastMessage.content === '🤔 Thinking...') {
lastMessage.content = data.data.content;
} else {
lastMessage.content += data.data.content;
}
}
return newMessages;
});
break;
case 'toolCall':
// Show when agent uses a tool
setMessages(prev => [...prev, {
id: Date.now().toString(),
content: `🔧 Using tool: ${data.data.toolName}`,
sender: 'agent',
timestamp: new Date()
}]);
break;
case 'response':
setMessages(prev => {
const newMessages = [...prev];
const lastMessage = newMessages[newMessages.length - 1];
if (lastMessage && lastMessage.sender === 'agent' && lastMessage.isStreaming) {
lastMessage.isStreaming = false;
lastMessage.content = data.data.content;
}
return newMessages;
});
break;
}
};
const sendMessage = async () => {
if (!currentInput.trim() || !isConnected) return;
const userMessage: Message = {
id: Date.now().toString(),
content: currentInput,
sender: 'user',
timestamp: new Date()
};
setMessages(prev => [...prev, userMessage]);
wsRef.current?.send(JSON.stringify({
type: 'message',
content: currentInput
}));
setCurrentInput('');
};
return (
<div className="flex flex-col h-screen max-w-4xl mx-auto p-4">
{/* Header */}
<div className="mb-4 p-4 bg-blue-50 rounded">
<div className="flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold">Saiki Chat - With Server Management</h1>
<div className="flex items-center gap-2">
<span className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></span>
<span className="text-sm text-gray-600">
{isConnected ? 'Connected to Saiki' : 'Connecting...'}
</span>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowServerPanel(!showServerPanel)}
className="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600"
>
{showServerPanel ? 'Hide' : 'Show'} Servers
</button>
<button
onClick={addServer}
className="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600"
>
Add Server
</button>
</div>
</div>
</div>
{/* Server Panel */}
{showServerPanel && (
<div className="mb-4 p-3 bg-gray-50 rounded">
<h3 className="font-semibold mb-2">Connected Servers ({servers.length}):</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{servers.map(server => (
<div key={server.id} className="flex justify-between items-center p-2 bg-white rounded border">
<span className="font-medium">{server.name}</span>
<span className={`px-2 py-1 rounded text-xs ${
server.status === 'connected'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{server.status}
</span>
</div>
))}
{servers.length === 0 && (
<div className="col-span-2 text-gray-500 text-center py-4">
No servers connected. Click "Add Server" to connect one!
</div>
)}
</div>
</div>
)}
{/* Messages */}
<div className="flex-1 overflow-y-auto space-y-4 mb-4">
{messages.map(message => (
<div
key={message.id}
className={`flex ${message.sender === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-xs lg:max-w-md px-4 py-2 rounded-lg ${
message.sender === 'user'
? 'bg-blue-500 text-white'
: message.content.startsWith('🔧')
? 'bg-purple-100 text-purple-800'
: 'bg-gray-200 text-gray-800'
} ${message.isStreaming ? 'animate-pulse border-2 border-blue-300' : ''}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
<div className="text-xs opacity-70 mt-1">
{message.timestamp.toLocaleTimeString()}
{message.isStreaming && ' • Streaming...'}
</div>
</div>
</div>
))}
</div>
{/* Input */}
<div className="flex gap-2">
<input
type="text"
value={currentInput}
onChange={(e) => setCurrentInput(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && sendMessage()}
placeholder="Type your message..."
className="flex-1 px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
disabled={!isConnected}
/>
<button
onClick={sendMessage}
disabled={!isConnected || !currentInput.trim()}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 disabled:opacity-50"
>
Send
</button>
</div>
</div>
);
};
What we've added:
- Server listing and management
- Dynamic server connection
- Tool usage indicators
- Collapsible server panel
Layer 4: Add Direct Tool Execution
Finally, let's add the ability to execute tools directly:
// Add this to the previous component or create AdvancedChat.tsx
const [availableTools, setAvailableTools] = useState<Record<string, any[]>>({});
const [showToolPanel, setShowToolPanel] = useState(false);
// Fetch tools for each server
const fetchServerTools = async (serverId: string) => {
try {
const response = await fetch(`http://localhost:3001/api/mcp/servers/${serverId}/tools`);
if (response.ok) {
const data = await response.json();
setAvailableTools(prev => ({
...prev,
[serverId]: data.tools
}));
}
} catch (error) {
console.error(`Failed to fetch tools for ${serverId}:`, error);
}
};
// Execute a tool directly
const executeTool = async (serverId: string, toolName: string) => {
const args = prompt(`Enter arguments for ${toolName} (JSON format):`);
let parsedArgs = {};
if (args) {
try {
parsedArgs = JSON.parse(args);
} catch {
alert('Invalid JSON format');
return;
}
}
try {
const response = await fetch(
`http://localhost:3001/api/mcp/servers/${serverId}/tools/${toolName}/execute`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(parsedArgs)
}
);
if (response.ok) {
const result = await response.json();
// Add tool result as a message
setMessages(prev => [...prev, {
id: Date.now().toString(),
content: `🔧 Tool Result (${toolName}):\n${JSON.stringify(result.data, null, 2)}`,
sender: 'agent',
timestamp: new Date()
}]);
} else {
alert('Tool execution failed');
}
} catch (error) {
console.error('Error executing tool:', error);
alert(`Error: ${error.message}`);
}
};
// Update fetchServers to also fetch tools
const fetchServers = async () => {
try {
const response = await fetch('http://localhost:3001/api/mcp/servers');
if (response.ok) {
const data = await response.json();
setServers(data.servers);
// Fetch tools for each connected server
data.servers.forEach((server: Server) => {
if (server.status === 'connected') {
fetchServerTools(server.id);
}
});
}
} catch (error) {
console.error('Failed to fetch servers:', error);
}
};
// Add this to your JSX after the server panel:
{showToolPanel && (
<div className="mb-4 p-3 bg-yellow-50 rounded">
<h3 className="font-semibold mb-2">Available Tools:</h3>
<div className="space-y-2">
{Object.entries(availableTools).map(([serverId, tools]) => (
<div key={serverId} className="border rounded p-2">
<h4 className="font-medium text-sm text-gray-700 mb-1">{serverId}:</h4>
<div className="flex flex-wrap gap-1">
{tools.map((tool: any) => (
<button
key={tool.name}
onClick={() => executeTool(serverId, tool.name)}
className="px-2 py-1 bg-yellow-200 hover:bg-yellow-300 rounded text-xs"
title={tool.description}
>
{tool.name}
</button>
))}
</div>
</div>
))}
</div>
</div>
)}
// Add tool panel toggle to your header buttons:
<button
onClick={() => setShowToolPanel(!showToolPanel)}
className="px-3 py-1 bg-yellow-500 text-white rounded hover:bg-yellow-600"
>
{showToolPanel ? 'Hide' : 'Show'} Tools
</button>
Summary: Progressive API Usage
We've built up our React app layer by layer:
- Layer 1: Basic synchronous messaging (
/api/message-sync
) - Layer 2: Real-time streaming (WebSocket events)
- Layer 3: Server management (
/api/mcp/servers
,/api/connect-server
) - Layer 4: Direct tool execution (
/api/mcp/servers/:id/tools/:tool/execute
)
Key Takeaways
🎯 Start Simple
- Begin with synchronous API calls
- Add complexity gradually
- Each layer builds on the previous
🔗 API Progression
- Synchronous → Streaming → Management → Direct Control
- Each API serves different use cases
- Mix and match based on your needs
🛠 Real-world Usage
- Most apps start with layers 1-2
- Server management (layer 3) is for power users
- Direct tool execution (layer 4) is for advanced integrations
📈 Scaling Considerations
- Cache server/tool information
- Handle connection states gracefully
- Implement proper error boundaries
- Consider user permission levels
Start with Layer 1 for your first integration, then add layers as your application grows!
Next Steps: Try building the basic version first, then gradually add each layer. For production patterns, see Advanced Patterns.