Skip to content

Instantly share code, notes, and snippets.

@wgnrai
Created March 11, 2026 06:54
Show Gist options
  • Select an option

  • Save wgnrai/d9eeed023063b178f97d773cf7401ced to your computer and use it in GitHub Desktop.

Select an option

Save wgnrai/d9eeed023063b178f97d773cf7401ced to your computer and use it in GitHub Desktop.
A minimal, self-contained chat interface that routes commands to specialized Agent Zero instances. Perfect for quick task delegation without the overhead of the full Agent Zero UI.

kellē-lite: A Lightweight AI Chat Interface for Agent Zero

Overview

kellē-lite is a Python-based webhook server with an HTML/JavaScript frontend that:

  • Provides a clean chat interface powered by Perplexity Sonar Pro
  • Routes @ commands to specialized Agent Zero agents
  • Maintains persistent conversation context per agent
  • Runs in the same container as Agent Zero for zero-latency communication
  • Exposes a single port with both UI and API

Architecture

┌─────────────────────────────────────────┐
│       Docker Container                  │
│  ┌─────────────────────────────────────┐ │
│  │  Agent Zero (kelle.ai)              │ │
│  │  Port 80: Web UI & API              │ │
│  └─────────────────────────────────────┘ │
│  ┌─────────────────────────────────────┐ │
│  │  kellē-lite Chat Server             │ │
│  │  Port 8088: HTML Chat + API         │ │
│  │  - Routes @commands to localhost:80 │ │
│  │  - Maintains context per agent      │ │
│  │  - Perplexity for base chat         │ │
│  └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
         ↓
   Cloudflare Tunnel
         ↓
    https://lite.example.com

Key Insight

Same-container communication beats external routing. Since kellē-lite and Agent Zero run in the same container, use direct localhost API calls instead of A2A protocol or external tunnels. This eliminates network complexity and authentication headaches.

Features

Chat Commands

  • @mac - Route to NullClaw agent (file ops, shell commands)
  • @kelle - Route to main kellē.ai orchestrator
  • @research - Route to Research agent

Default Behavior

Messages without @ commands use Perplexity Sonar Pro for instant responses.

Context Persistence

Each agent maintains its own context_id across messages, enabling multi-turn conversations while keeping agent contexts isolated from each other.

UI Features

  • Clean dark theme with brand logo
  • Avatar images (user & kellē)
  • Command hint buttons
  • Clear button to reset chat history
  • Real-time message status
  • Route display (shows which agent handled the message)

Setup

1. Create Directory Structure

/a0/usr/projects/executive_assistant/chat-lite/
├── index.html
├── server/
│   └── server.py
├── favicon.svg
├── k-lite.png          (brand logo)
├── wagner.svg          (user avatar)
└── kelleai.svg         (kellē avatar)

2. HTML Frontend (index.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>kellē-lite</title>
    <link rel="icon" type="image/svg+xml" href="favicon.svg">
    <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
    <style>
        * { box-sizing: border-box; margin: 0; padding: 0; }
        :root {
            --brand-blue: #6EA8DB;
            --brand-blue-dark: #5887DA;
        }
        body {
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
            background: #0a0a0b;
            color: #e5e5e5;
            height: 100vh;
            display: flex;
            flex-direction: column;
        }
        .header {
            padding: 16px 20px;
            border-bottom: 1px solid #222;
            display: flex;
            align-items: center;
            gap: 12px;
        }
        .header img {
            height: 24px;
        }
        .status {
            font-size: 12px;
            color: #666;
            margin-left: auto;
        }
        .status.connected { color: #F6BD17; }
        .status.error { color: #ef4444; }
        .chat-container {
            flex: 1;
            overflow-y: auto;
            padding: 20px 40px;
        }
        .message {
            display: flex;
            gap: 12px;
            margin-bottom: 16px;
            max-width: 85%;
        }
        .avatar {
            width: 36px;
            height: 36px;
            border-radius: 50%;
            flex-shrink: 0;
            object-fit: cover;
            overflow: hidden;
        }
        .message-content {
            flex: 1;
            min-width: 0;
        }
        .bubble {
            display: inline-block;
            padding: 12px 16px;
            border-radius: 16px;
            background: #1a1a1c;
            line-height: 1.6;
        }
        .message.user .bubble {
            background: var(--brand-blue);
            color: #000;
        }
        .bubble p {
            margin: 8px 0;
        }
        .bubble p:first-child { margin-top: 0; }
        .bubble p:last-child { margin-bottom: 0; }
        .bubble strong { font-weight: 600; }
        .bubble em { font-style: italic; }
        .bubble ul, .bubble ol {
            margin: 8px 0 8px 20px;
        }
        .bubble li { margin: 4px 0; }
        .bubble a {
            color: var(--brand-blue);
            text-decoration: underline;
        }
        .meta {
            font-size: 11px;
            color: #555;
            margin-top: 4px;
        }
        .route {
            font-size: 11px;
            color: #888;
            margin-top: 4px;
            font-style: italic;
        }
        .input-area {
            padding: 16px 40px;
            border-top: 1px solid #222;
            display: flex;
            gap: 12px;
            align-items: center;
        }
        .input-wrapper {
            flex: 1;
            display: flex;
            flex-direction: column;
            gap: 8px;
            align-self: flex-end;
        }
        textarea {
            width: 100%;
            padding: 12px 16px;
            border: 1px solid #333;
            border-radius: 12px;
            background: #111;
            color: #fff;
            font-size: 15px;
            resize: none;
            outline: none;
            font-family: inherit;
        }
        textarea:focus { border-color: var(--brand-blue); }
        .hints {
            font-size: 11px;
            color: #555;
            display: flex;
            align-items: center;
            gap: 8px;
        }
        .hints span {
            cursor: pointer;
        }
        .hints span:hover { color: var(--brand-blue); }
        .clear-btn {
            padding: 6px 10px;
            background: #444;
            color: #999;
            border: none;
            border-radius: 6px;
            font-size: 11px;
            cursor: pointer;
            transition: all 0.2s;
        }
        .clear-btn:hover {
            background: #555;
            color: #fff;
        }
        button#send {
            padding: 12px 20px;
            background: var(--brand-blue);
            color: #000;
            border: none;
            border-radius: 12px;
            font-size: 15px;
            font-weight: 500;
            cursor: pointer;
            transition: background 0.2s;
            height: 48px;
            align-self: flex-start;
        }
        button#send:hover { background: var(--brand-blue-dark); }
        button#send:disabled {
            background: #333;
            color: #666;
            cursor: not-allowed;
        }
    </style>
</head>
<body>
    <div class="header">
        <img src="k-lite.png" alt="kellē-lite">
        <span class="status" id="status">ready</span>
    </div>
    <div class="chat-container" id="chat">
        <div class="message assistant">
            <img src="wagner.svg" alt="kellē" class="avatar">
            <div class="message-content">
                <div class="bubble"><p>Hi Wágner! Ready to dig in?</p></div>
                <div class="meta">kellē-lite · just now</div>
            </div>
        </div>
    </div>
    <div class="input-area">
        <div class="input-wrapper">
            <textarea id="input" rows="1" placeholder="Describe your task or ask a question..." autofocus></textarea>
            <div class="hints">
                <span onclick="addCommand('@mac')">@mac</span>
                <span onclick="addCommand('@kelle')">@kelle</span>
                <span onclick="addCommand('@research')">@research</span>
                <button class="clear-btn" onclick="clearChat()">Clear</button>
            </div>
        </div>
        <button id="send" onclick="sendMessage()">Send</button>
    </div>
    <script>
        const WEBHOOK_URL = 'https://lite.kelle.ai';
        const chat = document.getElementById('chat');
        const input = document.getElementById('input');
        const sendBtn = document.getElementById('send');
        const status = document.getElementById('status');
        
        input.addEventListener('input', function() {
            this.style.height = 'auto';
            this.style.height = Math.min(this.scrollHeight, 150) + 'px';
        });
        input.addEventListener('keydown', function(e) {
            if (e.key === 'Enter' && !e.shiftKey) {
                e.preventDefault();
                sendMessage();
            }
        });
        
        function addCommand(cmd) {
            const current = input.value.trim();
            if (current && !current.startsWith('@')) {
                input.value = cmd + ' ' + current;
            } else if (!current) {
                input.value = cmd + ' ';
            }
            input.focus();
        }
        
        function clearChat() {
            chat.innerHTML = '<div class="message assistant"><img src="wagner.svg" alt="kellē" class="avatar"><div class="message-content"><div class="bubble"><p>Chat cleared. Ready for new tasks.</p></div><div class="meta">kellē-lite · just now</div></div></div>';
            input.value = '';
            input.style.height = 'auto';
            input.focus();
        }
        
        function detectRoute(message) {
            if (message.includes('@mac')) return 'NullClaw';
            if (message.includes('@kelle')) return 'kellē.ai';
            if (message.includes('@research')) return 'Research';
            return 'Chat';
        }
        
        function addMessage(content, isUser, route = null) {
            const div = document.createElement('div');
            div.className = 'message ' + (isUser ? 'user' : 'assistant');
            const time = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
            
            const avatarSrc = isUser ? 'kelleai.svg' : 'wagner.svg';
            const avatarAlt = isUser ? 'You' : 'kellē';
            const avatarHtml = '<img src="' + avatarSrc + '" alt="' + avatarAlt + '" class="avatar">';
            
            const senderName = isUser ? 'You' : 'kellē-lite';
            
            let bubbleContent;
            if (isUser) {
                const escaped = document.createElement('div');
                escaped.textContent = content;
                bubbleContent = '<p>' + escaped.innerHTML.replace(/\n/g, '<br>') + '</p>';
            } else {
                bubbleContent = marked.parse(content).replace(/```[\s\S]*?```/g, '');
            }
            
            div.innerHTML = avatarHtml +
                '<div class="message-content">' +
                '<div class="bubble">' + bubbleContent + '</div>' +
                '<div class="meta">' + senderName + ' · ' + time + '</div>' +
                (route && !isUser ? '<div class="route">via ' + route + '</div>' : '') +
                '</div>';
            
            chat.appendChild(div);
            chat.scrollTop = chat.scrollHeight;
        }
        
        async function sendMessage() {
            const message = input.value.trim();
            if (!message) return;
            const route = detectRoute(message);
            addMessage(message, true);
            input.value = '';
            input.style.height = 'auto';
            sendBtn.disabled = true;
            status.textContent = 'sending...';
            status.className = 'status';
            try {
                const response = await fetch(WEBHOOK_URL, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({ message: message })
                });
                if (!response.ok) throw new Error('HTTP ' + response.status);
                const data = await response.json();
                const reply = data.response || data.output || data.message || JSON.stringify(data);
                addMessage(reply, false, route);
                status.textContent = 'connected';
                status.className = 'status connected';
            } catch (err) {
                addMessage('Error: ' + err.message + '. Check if server is running.', false);
                status.textContent = 'error';
                status.className = 'status error';
            }
            sendBtn.disabled = false;
        }
    </script>
</body>
</html>

3. Python Server (server/server.py)

#!/usr/bin/env python3
import json
import re
import os
import urllib.request
from http.server import HTTPServer, BaseHTTPRequestHandler
import sys

PORT = int(os.environ.get('CHAT_LITE_PORT', 8088))
PERPLEXITY_API_KEY = os.environ.get('PERPLEXITY_API_KEY', '')
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

# Local Agent Zero API (same container)
EXTERNAL_API_URL = 'http://localhost:80/api_message'
EXTERNAL_API_KEY = os.environ.get('AGENT_ZERO_API_KEY', '')

AGENT_PROJECTS = {
    'nullclaw': 'nullclaw_agent',
    'kelle': 'kelle-ai',
    'research': 'researcher',
}

# Store context IDs per agent
contexts = {
    'nullclaw': None,
    'kelle': None,
    'research': None,
}

MIME_TYPES = {
    '.html': 'text/html; charset=utf-8',
    '.css': 'text/css; charset=utf-8',
    '.js': 'application/javascript; charset=utf-8',
    '.svg': 'image/svg+xml',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.gif': 'image/gif',
    '.ico': 'image/x-icon',
    '.json': 'application/json',
}

SYSTEM_PROMPT = """You are kellē-lite, the lightweight conversational interface for the kellē.ai ecosystem. You provide quick, helpful responses for Wágner dos Santos.

You can escalate to specialized agents when needed:
- @mac → NullClaw (Mac Studio file operations, shell commands)
- @kelle → kellē.ai (full orchestration, multi-agent coordination, complex tasks)
- @research → Research Agent (deep research, data analysis, academic-style reports)

Be brief, helpful, and suggest escalation when tasks require specialized capabilities beyond conversation."""

def detect_route(message):
    msg_lower = message.lower()
    if '@mac' in msg_lower:
        return 'nullclaw', re.sub(r'@mac', '', message, flags=re.I).strip()
    elif '@kelle' in msg_lower:
        return 'kelle', re.sub(r'@kelle', '', message, flags=re.I).strip()
    elif '@research' in msg_lower:
        return 'research', re.sub(r'@research', '', message, flags=re.I).strip()
    return 'chat', message

def call_perplexity(message):
    url = 'https://api.perplexity.ai/chat/completions'
    payload = {
        'model': 'sonar',
        'messages': [
            {'role': 'system', 'content': SYSTEM_PROMPT},
            {'role': 'user', 'content': message}
        ]
    }
    data = json.dumps(payload).encode('utf-8')
    req = urllib.request.Request(url, data=data, headers={
        'Authorization': f'Bearer {PERPLEXITY_API_KEY}',
        'Content-Type': 'application/json'
    }, method='POST')
    try:
        with urllib.request.urlopen(req, timeout=60) as resp:
            result = json.loads(resp.read().decode('utf-8'))
            return result['choices'][0]['message']['content']
    except Exception as e:
        return f"Chat error: {str(e)}"

def call_agent(agent_type, message):
    payload = {
        'message': message,
        'project': AGENT_PROJECTS.get(agent_type)
    }
    
    # Add context_id if we have one for this agent
    if contexts.get(agent_type):
        payload['context_id'] = contexts[agent_type]
    
    data = json.dumps(payload).encode('utf-8')
    req = urllib.request.Request(EXTERNAL_API_URL, data=data, headers={
        'Content-Type': 'application/json',
        'X-API-KEY': EXTERNAL_API_KEY
    }, method='POST')
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            result = json.loads(resp.read().decode('utf-8'))
            if isinstance(result, dict):
                # Store context_id for future messages from this agent
                if 'context_id' in result:
                    contexts[agent_type] = result['context_id']
                return result.get('response') or result.get('message') or json.dumps(result)
            return str(result)
    except Exception as e:
        return f"Agent error: {str(e)}"

class ChatHandler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        print(f"[chat-lite] {args[0]}")
    
    def send_json(self, status, data):
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Access-Control-Allow-Origin', '*')
        self.end_headers()
        self.wfile.write(json.dumps(data).encode('utf-8'))
    
    def do_OPTIONS(self):
        self.send_json(200, {})
    
    def do_POST(self):
        try:
            content_length = int(self.headers.get('Content-Length', 0))
            body = self.rfile.read(content_length).decode('utf-8')
            data = json.loads(body)
            message = data.get('message', '')
            if not message:
                self.send_json(400, {'error': 'Message required'})
                return
            route, cleaned_message = detect_route(message)
            if route == 'chat':
                response = call_perplexity(cleaned_message)
            else:
                response = call_agent(route, cleaned_message)
            self.send_json(200, {'response': response, 'route': route})
        except Exception as e:
            self.send_json(500, {'error': str(e)})
    
    def do_GET(self):
        if self.path in ('/', '/index.html'):
            file_path = os.path.join(BASE_DIR, 'index.html')
        else:
            file_path = os.path.normpath(os.path.join(BASE_DIR, self.path.lstrip('/')))
            if not file_path.startswith(BASE_DIR):
                self.send_json(403, {'error': 'Forbidden'})
                return
        
        if not os.path.isfile(file_path):
            self.send_json(404, {'error': 'Not found'})
            return
        
        try:
            with open(file_path, 'rb') as f:
                self.send_response(200)
                # Determine MIME type from file extension
                _, ext = os.path.splitext(file_path)
                mime_type = MIME_TYPES.get(ext.lower(), 'application/octet-stream')
                self.send_header('Content-Type', mime_type)
                self.send_header('Content-Length', os.path.getsize(file_path))
                self.end_headers()
                self.wfile.write(f.read())
        except Exception as e:
            self.send_json(500, {'error': str(e)})

if __name__ == '__main__':
    server = HTTPServer(('0.0.0.0', PORT), ChatHandler)
    print(f"kellē-lite running on port {PORT}")
    server.serve_forever()

4. Docker Compose Integration

services:
  agent-zero:
    container_name: kelle-ai-wagner-v2
    image: agent0ai/agent-zero:latest
    volumes:
      - ./a0:/a0
    ports:
      - "50080:80"
      - "8090:8088"
    environment:
      - CHAT_LITE_PORT=8088
      - PERPLEXITY_API_KEY=${PERPLEXITY_API_KEY}
      - AGENT_ZERO_API_KEY=${AGENT_ZERO_API_KEY}
    command: sh -c "python3 /a0/usr/projects/executive_assistant/chat-lite/server/server.py & exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf"

5. Environment Variables

Create a .env file in your docker-compose directory:

PERPLEXITY_API_KEY=your_perplexity_key_here
AGENT_ZERO_API_KEY=your_agent_zero_api_key_here
CHAT_LITE_PORT=8088

Critical: Both API keys must be set. The AGENT_ZERO_API_KEY is required for agent routing to work.

Deployment

Local Access

http://localhost:8088

Remote Access (via Cloudflare Tunnel)

https://lite.kelle.ai

Configure your Cloudflare tunnel to point to localhost:8088.

Context Persistence

Each agent maintains its own conversation context via context_id. This enables:

  • Multi-turn conversations: Ask follow-up questions to the same agent
  • Isolated contexts: Each agent (@mac, @kelle, @research) has its own memory
  • No cross-agent contamination: Asking @research won't see your @mac commands

Test it: Send @mac create test.txt, then @mac what did I just ask? — it will remember.

Key Lessons Learned

  1. Same-container = localhost API: Don't overcomplicate inter-service communication. Use http://localhost:PORT for services in the same container.

  2. A2A protocol is for external agents: A2A (Agent-to-Agent) is designed for remote agent communication across networks. For local communication, use the simpler External API.

  3. MIME types matter: Serve static assets (SVG, PNG, CSS, JS) with correct content types or browsers will reject them.

  4. Context per agent: Store context_id separately for each agent to enable persistent, isolated conversations.

  5. Perplexity is lightweight: Using a simple LLM API for base chat keeps kellē-lite minimal and fast. Only route complex tasks to Agent Zero.

Customization

  • Change the brand color: Edit :root { --brand-blue } in HTML
  • Add more agents: Add entries to AGENT_PROJECTS dict and update detectRoute() function
  • Change LLM: Replace call_perplexity() with your preferred API (OpenAI, Anthropic, etc.)
  • Disable Perplexity: Remove the @ command detection to force all messages to Agent Zero

Troubleshooting

Issue Solution
401 Unauthorized Check AGENT_ZERO_API_KEY environment variable is set correctly in docker-compose
Static assets not loading Verify MIME_TYPES dict matches file extensions; check browser network tab
Connection refused Ensure kellē-lite and Agent Zero are in same container or network accessible
Perplexity errors Verify API key is valid and has quota
Lost context on refresh Client-side chat history is cleared on page refresh; agent-side context persists via context_id

Next Steps

  • Customize the brand colors and avatars for your setup
  • Add more agents by extending AGENT_PROJECTS in server.py
  • Consider adding streaming responses for real-time agent output
  • Deploy via Cloudflare Tunnel for external access

About

  • kellē-lite was created by WGNR as a lightweight alternative to the kellē.ai fork of Agent Zero.
  • It demonstrates how to build minimal, purpose-built interfaces for Agent Zero orchestration.

For questions, feedback, or contributions:

  • GitHub: wgnr-ai
  • X: @wgnragency
  • Email: ai@wgnr.co
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment