Reference UI Architecture#
The lit-mux reference UI is a working example of how to build a frontend on the lit-mux API. It demonstrates key integration patterns that you can adapt for your own applications.
This is not a product. It's architecture documentation in the form of working code. Your implementation will likely look completely different—and that's the point.
What the Reference UI Demonstrates#
| Feature | API Pattern | Frontend Technique |
|---|---|---|
| Session list | GET /sessions, GET /agents/{id}/sessions |
Temporal grouping, search |
| Chat streaming | POST /sessions/{id}/stream (SSE) |
Server-Sent Events parsing |
| Message persistence | POST /sessions/{id}/messages |
Optimistic UI updates |
| Backend switching | GET /backends, GET /models |
Dynamic model selection |
| Agent selection | GET /agents |
Multi-agent routing |
| Voice input | N/A (browser API) | Web Speech API integration |
| Photo upload | POST /sessions/{id}/messages |
Base64 image in markdown |
| Session search | GET /agents/{id}/sessions?q= |
Highlight matching terms |
Core Integration Patterns#
1. Session Management#
Sessions are the fundamental unit of conversation state. Create a session before streaming messages.
// Create a session
const response = await fetch(`${API_URL}/sessions`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
backends: ['claude-cli'],
name: 'New Chat',
agent_id: 'default',
metadata: { username: currentUser }
})
});
const session = await response.json();
Key patterns:
- Store username in session metadata for user-specific filtering
- Sessions belong to agents—use agent_id to route conversations
- Session names are optional; auto-generate from first message if needed
2. Streaming Responses#
lit-mux uses Server-Sent Events (SSE) for streaming. Each event is a JSON object with a type field.
async function streamMessage(sessionId, message) {
const response = await fetch(`${API_URL}/sessions/${sessionId}/stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({ message })
});
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.slice(6));
handleStreamEvent(data);
}
}
}
}
function handleStreamEvent(data) {
switch (data.type) {
case 'content':
appendToMessage(data.content);
break;
case 'tool_start':
showToolIndicator(data.tool_name);
break;
case 'tool_result':
hideToolIndicator();
break;
case 'done':
finalizeMessage();
break;
case 'error':
showError(data.message);
break;
}
}
Stream event types:
- content - Text chunk to append
- tool_start - Tool execution beginning
- tool_result - Tool execution complete
- thinking - Model reasoning (if exposed)
- done - Stream complete
- error - Error occurred
3. Message Pagination#
Large conversations use pagination. Load recent messages first, then older on demand.
// Load most recent 50 messages
const response = await fetch(
`${API_URL}/sessions/${sessionId}/messages?limit=50&offset=0`
);
const { messages, total, has_more } = await response.json();
// Load more (older) messages
if (has_more) {
const moreResponse = await fetch(
`${API_URL}/sessions/${sessionId}/messages?limit=50&offset=50`
);
const older = await moreResponse.json();
prependMessages(older.messages);
}
Key patterns: - Default to loading last N messages (50 is reasonable) - Implement "Load more" at top of conversation - Consider infinite scroll for older messages
4. Backend and Model Selection#
Query available backends and models dynamically:
// Get available backends
const backends = await fetch(`${API_URL}/backends`).then(r => r.json());
// Returns: [{ name: 'claude-cli', enabled: true, status: 'healthy' }, ...]
// Get available models
const models = await fetch(`${API_URL}/models`).then(r => r.json());
// Returns: [{ id: 'claude-sonnet-4-20250514', backend: 'claude-cli' }, ...]
Key patterns: - Cache backend/model lists (they rarely change mid-session) - Store user's preferred backend/model in session metadata - Restore preference when loading existing sessions
5. Agent Selection#
For multi-agent deployments, let users choose which agent to chat with:
const agents = await fetch(`${API_URL}/agents`).then(r => r.json());
// Returns: [{ id: 'default', name: 'General Assistant', role: 'Assistant' }, ...]
// Create session for specific agent
const session = await fetch(`${API_URL}/sessions`, {
method: 'POST',
body: JSON.stringify({
backends: ['claude-cli'],
agent_id: selectedAgentId
})
});
6. Voice Input (Browser Feature)#
The reference UI uses the Web Speech API for voice input. This is a browser feature, not a lit-mux API.
const recognition = new (window.SpeechRecognition || window.webkitSpeechRecognition)();
recognition.continuous = true;
recognition.interimResults = true;
recognition.onresult = (event) => {
let transcript = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
transcript += event.results[i][0].transcript;
}
updateInputField(transcript);
};
// Push-to-talk: start on touchstart, stop on touchend
inputArea.addEventListener('touchstart', () => recognition.start());
inputArea.addEventListener('touchend', () => recognition.stop());
7. Photo Upload#
Photos are stored as markdown image syntax in messages:
async function uploadPhoto(file) {
// Convert to base64
const reader = new FileReader();
reader.readAsDataURL(file);
const base64 = await new Promise(resolve => {
reader.onload = () => resolve(reader.result);
});
// Store as markdown image
const imageMarkdown = ``;
// Persist to session (without triggering AI response)
await fetch(`${API_URL}/sessions/${sessionId}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
role: 'user',
content: imageMarkdown
})
});
}
Note: The POST /sessions/{id}/messages endpoint adds a message without triggering an AI response. Use POST /sessions/{id}/stream when you want the AI to respond.
8. Session Search#
Search across conversation history with query highlighting:
// Search sessions for an agent
const response = await fetch(
`${API_URL}/agents/${agentId}/sessions?q=${encodeURIComponent(query)}`
);
const sessions = await response.json();
// Sessions include match context for highlighting
sessions.forEach(session => {
if (session.match_context) {
highlightMatches(session.match_context, query);
}
});
Authentication Integration#
The reference UI integrates with Keycloak for enterprise SSO:
class AuthManager {
constructor(keycloakConfig) {
this.keycloak = new Keycloak(keycloakConfig);
}
async init() {
await this.keycloak.init({ onLoad: 'login-required' });
}
async makeRequest(url, options = {}) {
// Refresh token if needed
await this.keycloak.updateToken(30);
return fetch(url, {
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${this.keycloak.token}`
}
});
}
getUsername() {
return this.keycloak.tokenParsed?.preferred_username;
}
}
UI Patterns#
Temporal Grouping#
Group sessions by time period (Today, Yesterday, Last 7 Days, etc.):
function groupSessionsByTime(sessions) {
const now = new Date();
const today = startOfDay(now);
const yesterday = subDays(today, 1);
const lastWeek = subDays(today, 7);
return {
'Today': sessions.filter(s => new Date(s.updated_at) >= today),
'Yesterday': sessions.filter(s => {
const d = new Date(s.updated_at);
return d >= yesterday && d < today;
}),
'Last 7 Days': sessions.filter(s => {
const d = new Date(s.updated_at);
return d >= lastWeek && d < yesterday;
}),
// ...
};
}
Markdown Rendering#
Messages support full markdown including code blocks, tables, and images:
import { marked } from 'marked';
import hljs from 'highlight.js';
marked.setOptions({
highlight: (code, lang) => {
return hljs.highlightAuto(code, [lang]).value;
}
});
function renderMessage(content) {
return marked.parse(content);
}
Mobile Optimization#
Key patterns for mobile UX:
- Push-to-talk: Touch-and-hold the input area to record
- Session dropdown: Collapse session list into dropdown on narrow screens
- Touch targets: Minimum 44px for buttons and interactive elements
- Viewport handling: Account for mobile keyboard
Building Your Own Frontend#
Minimal Integration#
The simplest possible frontend:
<input id="input" type="text">
<button onclick="send()">Send</button>
<div id="output"></div>
<script>
async function send() {
const message = document.getElementById('input').value;
const response = await fetch('/sessions/my-session/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message })
});
const reader = response.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
document.getElementById('output').textContent +=
new TextDecoder().decode(value);
}
}
</script>
Framework Integration#
lit-mux works with any frontend framework:
- React: Use
useEffectfor streaming,useStatefor messages - Vue: Reactive refs for message state, watchers for streams
- Svelte: Stores for session state,
onMountfor initialization - Vanilla JS: The reference UI is vanilla—no framework required
Mobile Apps#
For native mobile apps:
- Use platform HTTP clients for streaming (URLSession, OkHttp)
- Implement SSE parsing manually or use a library
- Consider WebSocket for bidirectional communication
- Native speech APIs for voice input
Source Code#
The reference UI source is available in the lit-mux repository:
lit-mux/site/
├── index.html # Main HTML structure
├── css/styles.css # Styling
└── js/
├── chat-interface.js # Main application logic
├── session-manager.js # Session CRUD operations
├── auth-manager.js # Keycloak integration
└── message-renderer.js # Markdown rendering
Use this as a starting point, or build something completely different. The API is the contract—how you present it is up to you.