Pangram verdict · v3.3
We believe that this document is a mix of AI-generated, and human-written content
AI likelihood · overall
MixedArticle text · 1,591 words · 6 segments analyzed
09 May, 2026
I Here is k10s: https://github.com/shvbsle/k10s/tree/archive/go-v0.4.0 234 commits. ~30 weekends. Built entirely on vibe-coded sessions with Claude, whenever my tokens lasted long enough to ship something. I'm archiving my TUI tool and rewriting it from scratch. k10s started as a GPU-aware Kubernetes dashboard (and my first foray into building something serious with AI). Think k9s but built for the people running NVIDIA clusters, people who actually care about GPU utilization, DCGM metrics, and which nodes are sitting idle burning $32/hr. I built it in Go with Bubble Tea [1] and it worked. For a while... :( I learned over these 7 months is worth more than the 1690 lines of model.go I'm throwing away. And I think anyone doing serious vibe-coding can benefit from this, because this part doesn't surface much (I feel it gets buried under the demo reels and the velocity wins). tl;dr: AI writes features, not architecture. The longer you let it drive without constraints, the worse the wreckage gets. The velocity makes you think you're winning right up until the moment everything collapses simultaneously. II vibe coding highI started k10s in late September 2025. The first few weeks were magic. I'd prompt Claude with "add a pods view with live updates" and boom, it worked. Resource list views, namespace filtering, log streaming, describe panels, keyboard navigation.
Each feature landed clean because the project was small enough that the AI could hold the whole thing in context. The basic k9s clone took maybe 3 weekends. Resource views for pods, nodes, deployments, services. A command palette. Watch-based live updates. Vim keybindings. All working, all vibe-coded in single sessions. I was building at maybe 10x my normal speed and it felt incredible. Then I wanted the main selling point. The whole reason k10s exists is the GPU fleet view. A dedicated screen that shows you every node's GPU allocation, utilization from DCGM, temperature, power draw, memory. Not buried in kubectl describe node output, but right there in a purpose-built table with color-coded status. Idle nodes in yellow. Busy in green. Saturated in red.
The fleet view on mock GPU nodes
And Claude one-shot it. I prompted for the fleet view, it generated the FleetView struct, the tab filtering (GPU/CPU/All), the custom rendering with allocation bars. It looked beautiful. I was riding the high. Then I typed :rs pods to switch back to the pods view. Nothing rendered. The table was empty. Live updates had stopped. I switched to nodes, it showed stale data from the fleet view's filter. I went back to fleet, the tab counts were wrong. The god object had consumed itself. This is the title of the blog post. This is where I intervened for the first time. For 7 months I'd been prompting and shipping without ever sitting down and actually reading the code Claude wrote.
I'd look at the diff, verify it compiled, test the happy path, move on. But now something was fundamentally broken and I couldn't just prompt my way out of it. So I sat down and read model.go. All 1690 lines. I was horrified. Here's what it looked like. One struct to rule them all: type Model struct { // 3rd party UI components table table.Model paginator paginator.Model commandInput textinput.Model help help.Model
// cluster info and state k8sClient *k8s.Client currentGVR schema.GroupVersionResource resourceWatcher watch.Interface resources []k8s.OrderedResourceFields listOptions metav1.ListOptions clusterInfo *k8s.ClusterInfo logLines []k8s.LogLine describeContent string currentNamespace string navigationHistory *NavigationHistory logView *LogViewState describeView *DescribeViewState viewMode ViewMode viewWidth int viewHeight int err error pluginRegistry *plugins.Registry helpModal *HelpModal describeViewport *DescribeViewport logViewport *LogViewport logStreamCancel func() logLinesChan <-chan k8s.LogLine horizontalOffset int mouse *MouseHandler fleetView *FleetView creationTimes []time.Time allResources []k8s.OrderedResourceFields // fleet's unfiltered set allCreationTimes []time.Time // fleet's timestamps rawObjects []unstructured.Unstructured ageColumnIndex int // ... }
UI widgets. K8s client. Per-view state for logs, describe, fleet. Navigation history. Caching. Mouse handling. All in one struct. And the Update() method was a 500-line function dispatching on msg.(type) with 110 switch/case branches. This is the moment I stopped vibe-coding and started thinking.
Ok I guess I'll let you copy logs with your mouse.
What could go wrong?
III five tenets from the wreckageHere's what I extracted from 7 months of watching AI generate a codebase that slowly ate itself. Each of these is something I did wrong, why it happens with AI-assisted coding, and what you should actually put in your CLAUDE.md or agents.md to prevent it.
Tenet 1: AI builds features, not architecture. Every time I prompted Claude for a feature, it delivered. Perfectly. The fleet view worked on the first try. Log streaming worked. Mouse support worked. The problem is that each feature was implemented in the context of "make this work right now" without any awareness of the 49 other features sharing the same state. Here's what the resourcesLoadedMsg handler looks like. This is the code that runs every time you switch views: case resourcesLoadedMsg: m.logLines = nil // Clear log lines when loading resources m.horizontalOffset = 0 // Reset horizontal scroll on resource change
if m.currentGVR != msg.gvr && m.resourceWatcher != nil { m.resourceWatcher.Stop() m.resourceWatcher = nil } m.currentGVR = msg.gvr m.currentNamespace = msg.namespace m.listOptions = msg.listOptions m.rawObjects = msg.rawObjects
// For nodes: store the full unfiltered set, classify, then filter if msg.gvr.Resource == k8s.ResourceNodes && m.fleetView != nil { m.allResources = msg.resources m.allCreationTimes = msg.creationTimes if len(msg.rawObjects) > 0 { m.fleetView.ClassifyAndCount(m.rawObjectPtrs()) } m.applyFleetFilter() } else { m.resources = msg.resources m.creationTimes = msg.creationTimes m.allResources = nil m.allCreationTimes = nil }
See the if msg.gvr.Resource == k8s.ResourceNodes && m.fleetView != nil conditional? That's the fleet view being special-cased inside the generic resource loading path. Every new view that needed custom behavior got another branch here. And every branch needed to manually clear the right combination of fields or the previous view's data would bleed through.
How many = nil cleanup lines exist in this file? I counted: m.logLines = nil // Clear log lines when loading resources m.allResources = nil // Clear fleet data when not on nodes m.resources = nil // Clear resources when loading logs m.resources = nil // Clear resources when loading describe view m.logLines = nil // Clear log lines when loading describe view m.resources = nil // Clear resources when loading yaml view m.logLines = nil // Clear log lines when loading yaml view m.logLines = nil // ... two more in other handlers m.logLines = nil
Nine manual nil assignments scattered across a 1690-line file. Miss one and you get ghost data from the previous view. This is what happens when there's no view isolation. AI can't see this pattern decaying over time because each prompt only touches one code path. What to do instead: Write the architecture yourself before any code. Not a vague design doc. A concrete set of interfaces, message types, and ownership rules. Then put those rules in your CLAUDE.md so the AI sees them on every prompt: # Architecture Invariants (CLAUDE.md)
- Each view implements the View trait. Views do NOT access other views' state. - All async data arrives via AppMsg variants. No direct field mutation from background tasks. - Adding a new view MUST NOT require modifying existing views. - The App struct is a thin router. It owns navigation and message dispatch. Nothing else.
The AI will follow these if you write them down. It just won't invent them for you.
Tenet 2: The god object is the default AI artifact. AI gravitates toward single-struct-holds-everything because it satisfies the immediate prompt with minimal ceremony. But it gets worse. Because there's no view isolation, key handling becomes a nightmare. Here's the actual key dispatch for the s key: case m.config.KeyBind.For(config.ActionToggleAutoScroll, key): if m.currentGVR.Resource == k8s.ResourceLogs { m.logView.Autoscroll = !m.logView.Autoscroll if m.logView.Autoscroll { m.table.GotoBottom() } return m, nil } // Shell exec for pods and containers views if m.currentGVR.Resource == k8s.
ResourcePods { // ... 20 lines to look up selected pod, get name, namespace ... return m, m.commandWithPreflights( m.execIntoPod(selectedName, selectedNamespace), m.requireConnection, ) } if m.currentGVR.Resource == k8s.ResourceContainers { // ... container exec logic ... return m, m.commandWithPreflights(m.execIntoContainer(), m.requireConnection) } return m, nil
One keybinding. Three completely different behaviors depending on which view you're in. The s key means "autoscroll" in logs, "shell" in pods, and "shell into container" in containers. This is all in one flat switch because there are no per-view key maps. The AI generated this because I said "add shell support for pods" and it found the nearest key handler and jammed it in. And look at how Enter works. This is the drill-down handler: case m.config.KeyBind.For(config.ActionSubmit, key): // Special handling for contexts view if m.currentGVR.Resource == "contexts" { // ... 12 lines ... return m, m.executeCtxCommand([]string{contextName}) } // Special handling for namespaces view if m.currentGVR.Resource == "namespaces" { // ... 12 lines ... return m, m.executeNsCommand([]string{namespaceName}) } if m.currentGVR.Resource == k8s.ResourceLogs { return m, nil } // ... 25 more lines of generic drill-down ...
Every view is a conditional in a flat dispatch. There are 20+ occurrences of m.currentGVR.Resource == used as a type discriminator in this single file. Not types. String comparisons. Every new view means touching every handler. What to do instead: Put this in your CLAUDE.md: # State Ownership Rules
- NEVER add fields to the App/Model struct for view-specific state. - Each view is a separate struct implementing the View trait/interface. - Each view declares its own key bindings. The app dispatches keys to the active view. - If you need to add a keybinding, add it to the relevant view's keymap, not a global one. - Adding a view means adding a file.