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
┌─────────────────────────────────────────┐
│ 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
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.
@mac- Route to NullClaw agent (file ops, shell commands)@kelle- Route to main kellē.ai orchestrator@research- Route to Research agent
Messages without @ commands use Perplexity Sonar Pro for instant responses.
Each agent maintains its own context_id across messages, enabling multi-turn conversations while keeping agent contexts isolated from each other.
- 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)
/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)
<!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>#!/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()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"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=8088Critical: Both API keys must be set. The AGENT_ZERO_API_KEY is required for agent routing to work.
http://localhost:8088
https://lite.kelle.ai
Configure your Cloudflare tunnel to point to localhost:8088.
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.
-
Same-container = localhost API: Don't overcomplicate inter-service communication. Use
http://localhost:PORTfor services in the same container. -
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.
-
MIME types matter: Serve static assets (SVG, PNG, CSS, JS) with correct content types or browsers will reject them.
-
Context per agent: Store
context_idseparately for each agent to enable persistent, isolated conversations. -
Perplexity is lightweight: Using a simple LLM API for base chat keeps kellē-lite minimal and fast. Only route complex tasks to Agent Zero.
- Change the brand color: Edit
:root { --brand-blue }in HTML - Add more agents: Add entries to
AGENT_PROJECTSdict and updatedetectRoute()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
| 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 |
- Customize the brand colors and avatars for your setup
- Add more agents by extending
AGENT_PROJECTSin server.py - Consider adding streaming responses for real-time agent output
- Deploy via Cloudflare Tunnel for external access
- 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