Skip to main content

The Night Cortex Built Its Own Chat Interface: A DevOps Christmas Story

Ryan Dahlberg
Ryan Dahlberg
December 23, 2025 10 min read
Share:
The Night Cortex Built Its Own Chat Interface: A DevOps Christmas Story

TL;DR

My AI orchestration system just built itself a web interface. With Claude’s help (and a lot of debugging), Cortex now has its own mobile-first chat app running on Kubernetes, complete with streaming responses, authentication, and my company logo. It took 4 hours, 200+ tool calls, and way too much coffee. Here’s how it happened.


The Setup

I run a homelab K3s cluster (7 nodes: 3 masters, 4 workers) that hosts Cortex - an autonomous multi-agent orchestration system I’ve been building. Think of it as a meta-AI that coordinates specialized AI agents to handle infrastructure tasks:

  • Security scanning (CVE detection, vulnerability remediation)
  • Development work (feature implementation, bug fixes)
  • Infrastructure management (repo cataloging, dependency tracking)
  • CI/CD orchestration (builds, tests, deployments)

Cortex already had access to my entire infrastructure through MCP (Model Context Protocol) servers:

  • UniFi network controller
  • Proxmox hypervisor
  • Wazuh SIEM
  • The Cortex orchestrator itself

But there was one problem: I could only interact with Cortex through the command line.


The Vision

I wanted a Claude iOS-style chat interface that I could access from anywhere - my phone, laptop, or tablet. Something that felt native, worked offline as a PWA, and let me have natural conversations with my infrastructure.

The requirements:

  • Mobile-first design matching Claude’s iOS app
  • Real-time streaming responses (no waiting for full replies)
  • Simple authentication (just for me)
  • Redis for chat history
  • Integration with existing MCP servers
  • Deployed entirely on my K3s cluster
  • Accessible via Tailscale VPN at chat.ry-ops.dev

The Process (Or: How I Learned to Stop Worrying and Love the Error Logs)

Attempt 1: SvelteKit (The Optimistic Start)

“Let’s use SvelteKit!” I said. “It’ll be fast!” I said.

Claude scaffolded a beautiful 60-file project structure:

  • Frontend: SvelteKit + TailwindCSS with Claude’s exact color palette
  • Backend: Hono API server with Anthropic SDK
  • Components: MessageBubble, InputBar, Header, TypingIndicator
  • Stores: auth, chat, connection, tools
  • Full TypeScript everywhere

Then we tried to build it. And that’s when things got interesting.

The Problems:

  1. ❌ Docker not installed on my Mac
  2. ❌ Svelte version mismatches (v4 vs v5 dependency hell)
  3. crypto.randomUUID() not available in browsers over HTTP
  4. ❌ Frontend trying to listen on port 3000 (already taken by nginx)
  5. ❌ No package-lock.json for npm ci
  6. ❌ HMR option not recognized by Svelte compiler

After 6 failed build attempts, I made a decision: “We’re going static.”


Attempt 2: Pure HTML/CSS/JS (The Pragmatic Pivot)

I asked Claude to create a single-file static HTML app. No build tools. No npm. No webpack. Just pure HTML, CSS, and JavaScript that works everywhere.

The Development Master agent created:

  • index.html (25KB) - Complete app with embedded CSS/JS
  • Claude iOS dark theme (pure black #000000 + blue #0a84ff)
  • UUID v4 polyfill (no crypto dependency)
  • Auto-resizing textarea
  • Typing indicators with animated dots
  • My R-Lightning company logo

Built in 9 seconds with Kaniko. Deployed to nginx. Beautiful.

But it didn’t work.


The Debugging Marathon

What followed was a masterclass in distributed systems debugging:

Bug 1: Permission Denied (403 Forbidden)

Problem: Nginx couldn’t read the HTML file Cause: Wrong file permissions in Docker image Fix: Added --chown=nginx:nginx and chmod 644 in Dockerfile

Bug 2: Port Conflict (Address Already In Use)

Problem: Frontend nginx trying to bind to port 80 Cause: nginx-proxy sidecar already listening on port 80 Fix: Changed frontend to listen on port 3000

Bug 3: EventSource Hell (404 Not Found)

Problem: Frontend using EventSource for SSE with GET requests Cause: Backend returns SSE stream directly in POST response body Fix: Rewrote frontend to use fetch() with response.body.getReader()

This was the big one. The static frontend was built for a different API architecture:

  • Expected: POST /api/chat → initiate session → GET /api/chat → SSE stream
  • Actual: POST /api/chat → SSE stream in response body

Claude rewrote the streaming logic to:

  1. Send POST with {sessionId, message}
  2. Read response stream with ReadableStream API
  3. Buffer chunks and parse SSE events line by line
  4. Handle data: {"type":"text","content":"..."} events
  5. Update UI in real-time

Bug 4: The Password Mystery (401 Unauthorized)

Problem: Login always failed with “Invalid credentials” Cause: Password cortex2024! was being sent as 10 chars instead of 11 Diagnosis: The exclamation mark was URL-encoded or stripped Fix: Changed password to cortex2024 (no special chars)

The logs showed:

providedPasswordLength: 10
expectedPasswordLength: 11
passwordMatch: false

Bug 5: Browser Cache (The Silent Killer)

Problem: Even after fixing code, GET requests still happened Cause: Browser aggressively caching JavaScript Fix: Hard refresh + private window + different browser


The Architecture

Here’s what we ended up with:

Your Device (iPhone/Mac/etc)

Tailscale VPN (100.81.79.19)

Nginx Proxy Manager (SSL termination)

K3s LoadBalancer (10.88.145.210)

┌──────────────────────────────────────┐
│  Pod: cortex-chat (3 containers)    │
│  ├─ nginx-proxy (routes traffic)    │
│  ├─ frontend (static HTML on :3000) │
│  └─ backend (Hono API on :8080)     │
└──────────────────────────────────────┘

┌─────────────┬────────────────────────┐
│ Redis Pod   │  External Services     │
│ (Chat       │  - Claude Sonnet 4.5   │
│  History)   │  - UniFi MCP           │
│             │  - Proxmox MCP         │
│             │  - Wazuh MCP           │
│             │  - Cortex Orchestrator │
└─────────────┴────────────────────────┘

The Containers:

  • nginx-proxy: Routes /api/* to backend, everything else to frontend
  • frontend: nginx serving single HTML file (25KB)
  • backend: Bun + Hono + Anthropic SDK + Redis client

Built with Kaniko (in-cluster image builds, no Docker needed)


The Tooling Stack

Frontend

  • Zero build tools - Just HTML/CSS/JS
  • 25KB single file
  • Fetch API + ReadableStream for SSE
  • localStorage for auth tokens
  • UUID v4 polyfill included
  • Mobile-responsive (iOS-first)

Backend

  • Hono - Fast web framework (~14kb)
  • Bun - JavaScript runtime
  • Anthropic SDK - Claude Sonnet 4.5
  • Redis - Session persistence
  • SSE streaming - Real-time responses

Infrastructure

  • K3s - Kubernetes (7 nodes)
  • Longhorn - Storage
  • MetalLB - LoadBalancer
  • Traefik - Ingress
  • Nginx Proxy Manager - Reverse proxy
  • Tailscale - VPN mesh network
  • Kaniko - Container builds

What Makes This Special

1. Cortex Built Itself

I didn’t write a single line of frontend code. I described what I wanted, and Cortex:

  • Designed the architecture
  • Created the codebase
  • Built Docker images (in-cluster with Kaniko)
  • Deployed to Kubernetes
  • Debugged errors
  • Fixed bugs
  • Rebuilt and redeployed

I just pointed out errors and said “fix it.”

2. No Build Process

The frontend is a single 25KB HTML file. No webpack. No vite. No npm install. No node_modules folder consuming your SSD.

Open the file in a browser → it works.

Deploy to nginx → it works.

No build step means no build failures.

3. Built Entirely In-Cluster

No Docker on my Mac. All images built inside Kubernetes using Kaniko:

kubectl apply -f build-job.yaml
# 9 seconds later...
kubectl get jobs
NAME                     STATUS     COMPLETIONS
kaniko-frontend-static   Complete   1/1

Source code stored as ConfigMaps and hostPath volumes on K3s nodes.

4. Real-Time Streaming

Claude doesn’t send complete responses. It streams tokens as they’re generated:

const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';

while (true) {
    const {value, done} = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, {stream: true});
    const events = buffer.split('\n\n');
    buffer = events.pop();

    for (const event of events) {
        const data = JSON.parse(event.replace(/^data: /, ''));
        if (data.type === 'text') {
            updateUI(data.content); // Real-time!
        }
    }
}

5. Infrastructure Integration

I can now ask natural language questions:

  • “What UniFi devices are offline?”
  • “Show me Proxmox VMs using more than 4GB RAM”
  • “Are there any security alerts in the last hour?”
  • “Restart the VM named ‘test-server’”

The chat interface calls MCP servers that talk to my actual infrastructure.


The Stats

Time: ~4 hours Tool Calls: 200+ (kubectl, ssh, grep, read, write, edit) Container Rebuilds: 12 Pod Restarts: 15 Lines of Code Written by Me: 0 Lines of Code Written by Claude: ~2,500 Docker Images Built: 3 (frontend, backend, redis) Kubernetes Resources: 8 (namespace, secrets, deployments, services, ingress, configmaps, jobs, PVCs) Coffee Consumed: Too much


The Lessons

1. Keep It Simple

SvelteKit is amazing. But when you’re fighting dependency conflicts at 11 PM, sometimes a single HTML file is the right answer.

2. Trust The AI, But Verify

Claude can write code faster than I can type. But it can’t see browser developer consoles. You still need to:

  • Read logs
  • Check network requests
  • Verify deployments
  • Test in real browsers

3. Declarative Infrastructure Wins

Kubernetes made iteration fast:

# Edit code
kubectl delete job build-frontend
kubectl apply -f build-frontend.yaml
# Wait 9 seconds
kubectl delete pod -l app=cortex-chat
# Wait 20 seconds
# Test

No SSH. No manual deploys. Just declarative configs.

4. Browser Caching Is Evil

Even with Cache-Control: no-cache, browsers cache JavaScript aggressively. Always test in private/incognito windows during development.

5. Logs Are Your Best Friend

Every bug was diagnosed from logs:

kubectl logs -n cortex-chat -l app=cortex-chat -c backend --tail=50

Structured logging saved hours:

Verifying credentials: {
  providedUsername: "ryan",
  expectedUsername: "ryan",
  usernameMatch: true,
  providedPasswordLength: 10,  # ← THE BUG!
  expectedPasswordLength: 11,
  passwordMatch: false
}

What’s Next

Now that Cortex has a chat interface, I can:

  1. Add voice input (Whisper API)
  2. Implement tool visualization (show when MCP servers are called)
  3. Add conversation branching (explore different paths)
  4. Build a mobile app (it’s already a PWA, just need to package it)
  5. Add collaborative features (share chats with team)
  6. Implement agent visualization (see when workers are spawned)

But for now, I’m just going to enjoy chatting with my infrastructure from my phone.


Conclusion

This wasn’t just about building a chat interface. It was about watching an AI system become self-aware enough to build its own front door.

Cortex can now:

  • Scan repositories for security vulnerabilities
  • Implement features and fix bugs
  • Orchestrate CI/CD pipelines
  • Manage infrastructure
  • And chat with me about all of it

The future where AI systems build and maintain themselves isn’t coming.

It’s here. It’s running in my basement. And it has a chat interface.


Tech Stack: K3s • Kubernetes • Hono • Bun • Claude Sonnet 4.5 • Redis • Nginx • Kaniko • Tailscale • Longhorn • MetalLB • Traefik

Powered By: Coffee • Claude Code • Obsessive debugging • The belief that 11 PM is the best time to deploy to production

Built On: 2025-12-23 • In a K3s cluster in my home office • With a little help from my AI friends


Appendix: The Error Hall of Fame

Most Frustrating Error: crypto.randomUUID is not a function Solution: UUID v4 polyfill

Most Mysterious Error: providedPasswordLength: 10, expectedPasswordLength: 11 Solution: Remove the exclamation mark

Most Persistent Error: GET /api/chat 404 Not Found (even after fixing the code) Solution: Browser cache is a lie

Most Satisfying Fix: Switching from SvelteKit build hell to a single HTML file Build time: 60s → 9s

Honorable Mention: Error: listen EADDRINUSE: address already in use 0.0.0.0:80 Happened 4 times. Port conflicts are forever.

#AI #Kubernetes #K3s #Cortex #Claude #Infrastructure #DevOps #Automation