Skip to content

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 = `![Uploaded photo](${base64})`;

    // 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.

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 useEffect for streaming, useState for messages
  • Vue: Reactive refs for message state, watchers for streams
  • Svelte: Stores for session state, onMount for 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.