NodeOps
UK
Blog/How We Built an Agent-Native Marketplace With a Bubble Tea TUI in Go

Apr 20, 2026

9 min read

How We Built an Agent-Native Marketplace With a Bubble Tea TUI in Go

NodeOps

NodeOps

How We Built an Agent-Native Marketplace With a Bubble Tea TUI in Go

When we set out to build CreateOS, the marketplace was going to be a web page. You'd go to a URL, browse Skills in a grid, click to purchase, and get a dashboard row. Standard stuff.

Then we actually used it. Every time we wanted to find a Skill while writing code, we had to leave the editor, open a browser, remember the URL, search, find, copy an ID, come back, paste. The context switch was absurd given that the whole platform is terminal-native.

So we built the marketplace inside the terminal. createos skills catalog opens a full-screen TUI: browse, search, paginate, view details, purchase, done. You never leave your shell.

This post is the engineering story of how it's built. The stack is Go 1.25, Bubble Tea for the MVU runtime, and Lipgloss for styling. The code is open source at github.com/NodeOps-app/createos-cli. We'll cover the architecture, the async patterns, the pagination model, the search mode, the purchase confirmation flow, and the non-obvious pitfalls we hit along the way. There's also a broader point at the end: why an agent-native platform also benefits from a first-class human TUI.

Why a TUI, specifically

Three reasons we didn't go the web-first route:

  1. Context switching is the whole problem. Developers using a CLI-first deploy tool want to stay in the terminal. A marketplace on a web page breaks that flow.
  2. Scripting. A TUI composes with the rest of the terminal: pipe output, run from a tmux pane, spawn from a Makefile. Not useful for browsing, but the same commands (createos skills purchased, etc.) work non-interactively for CI.
  3. Agent-adjacent. The marketplace is ultimately for AI agents to consume (via MCP tool discovery), but the humans configuring those agents also need a way to browse what's available. A TUI is the right primitive for humans who already live in a shell.

Why Bubble Tea

We evaluated three Go TUI libraries:

  • Bubble Tea (MVU, functional, Elm-inspired)
  • tcell / tview (imperative, widget-based)
  • Terminal UI (termui) (dashboard-focused, more for charts and gauges)

Bubble Tea won because:

  • The MVU model scales. Our TUI has four views (list, detail, search, confirm purchase), each with distinct keyboard shortcuts and async behavior. MVU keeps that complexity in explicit state transitions instead of scattered event handlers.
  • Async is idiomatic. Network calls (load page, purchase) are just tea.Cmd values that produce messages. No threads, no callbacks, no race conditions to reason about.
  • The Charm ecosystem is excellent. Lipgloss for styling, Bubbles for pre-built components, Wish if you ever want to serve a TUI over SSH.
  • It's fast. Bubble Tea renders diffs, not full screens. A fast keyboard user doesn't see tearing.

Architecture: four views, one model

Every Bubble Tea program follows the MVU loop: init, update, view. Our catalog screen has a single model that tracks which of four views is currently active:

type catalogView int

const (
    catalogListView catalogView = iota
    catalogDetailView
    catalogConfirmPurchaseView
    catalogSearchView
)

type catalogListModel struct {
    skills                []api.Skill
    cursor                int
    currentView           catalogView
    pagination            api.Pagination
    pageNumber            int
    searchText            string
    purchasedIDsBySkillID map[string]string
    searching             bool
    purchasing            bool
    statusMsg             string
    statusErr             bool
    client                *api.APIClient
    // ...
}

Each view has its own key bindings and its own render function. Transitions between views are state mutations inside Update, not separate programs. This is the MVU discipline: one model, one update function, branch on the state.

The Update loop: where complexity lives

Update is the heart of the TUI. It receives messages (keypresses, network responses, ticks) and returns a new model and optionally a command. For the list view, simplified:

func (m catalogListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case catalogPageLoadedMsg:
        m.searching = false
        if msg.err != nil {
            m.statusErr = true
            m.statusMsg = "Error: " + msg.err.Error()
            return m, nil
        }
        m.skills = msg.skills
        m.pagination = msg.pagination
        m.pageNumber = msg.pageNumber
        m.cursor = 0
        return m, nil

    case tea.KeyMsg:
        switch m.currentView {
        case catalogListView:
            return m.updateListView(msg)
        case catalogDetailView:
            return m.updateDetailView(msg)
        case catalogSearchView:
            return m.updateSearchView(msg)
        case catalogConfirmPurchaseView:
            return m.updateConfirmView(msg)
        }
    }
    return m, nil
}

Two rules we enforce:

  1. Update never blocks. Network calls become tea.Cmd values that get dispatched by the runtime. The goroutine running Update stays hot.
  2. Each view is a method. updateListView, updateDetailView, etc. Keeps the main switch readable and makes each view testable in isolation.

Async without the pain

Loading a page of skills is a network round-trip. In an imperative TUI you'd spawn a goroutine, write to a channel, and reconcile on the main thread. In Bubble Tea, you return a tea.Cmd:

func loadCatalogPage(client *api.APIClient, searchText string, pageNumber int) tea.Msg {
    skills, pagination, err := client.ListAvailableSkillsForPurchase(searchText, pageNumber, catalogPageSize)
    return catalogPageLoadedMsg{
        skills:     skills,
        pagination: pagination,
        pageNumber: pageNumber,
        err:        err,
    }
}

// In Update, when the user presses 'n':
case "n":
    if m.hasNextPage() && !m.searching {
        m.searching = true
        return m, func() tea.Msg {
            return loadCatalogPage(m.client, m.searchText, m.pageNumber+1)
        }
    }

The runtime runs the command on a background goroutine, and when it completes, the resulting message is fed back into Update on the main loop. No locking. No channels to reason about. The searching flag prevents double-dispatch, so if the user mashes n three times we only fire one request.

Pagination: small decisions that add up

The server returns paginated results with a pagination object. The TUI respects that:

func (m catalogListModel) hasNextPage() bool {
    return m.pagination != nil && m.pagination.HasNext
}

We made a deliberate choice not to do infinite scroll. Infinite scroll in a terminal looks fine for five items and terrible for five thousand. Explicit n for next and p for previous gives the user control and makes the cursor position predictable.

We also reset the cursor to 0 on page load. Otherwise a user on row 15 who hits n would stay on row 15 of the next page, which is disorienting. Small thing, big UX win.

Search mode: a view is cheap, use one

The first version of search was inline: press /, get a thin input at the bottom. This broke when the search text got long and wrapped. Fixed-height views are lies in a terminal.

The second version makes search a whole view:

case catalogSearchView:
    switch msg.String() {
    case "q", "ctrl+c":
        return m, tea.Quit
    case "esc":
        m.currentView = catalogListView
        return m, nil
    case "enter":
        // Fire the search
        m.searching = true
        m.pageNumber = 0
        return m, func() tea.Msg {
            return loadCatalogPage(m.client, m.searchText, 0)
        }
    case "backspace":
        if len(m.searchText) > 0 {
            m.searchText = m.searchText[:len(m.searchText)-1]
        }
        return m, nil
    default:
        if len(msg.String()) == 1 {
            m.searchText += msg.String()
        }
        return m, nil
    }

A whole view for search costs nothing extra in the MVU model: no new process, no new loop, just a state value and a render function. The lesson: when a UI mode is distinct enough to have different keybindings, it's a view.

Purchase confirmation: the two-step

Purchasing costs credits. We never wanted a single keystroke to deduct credits, so the purchase flow has its own view: press b on the detail screen, get a confirmation view, press y to confirm or esc to cancel.

case catalogConfirmPurchaseView:
    switch msg.String() {
    case "y", "Y":
        m.purchasing = true
        skill := m.skills[m.cursor]
        return m, m.purchaseCmd(skill.ID)
    case "n", "N", "esc":
        m.currentView = catalogDetailView
        return m, nil
    }

Two-step confirmation for anything that charges money is not optional. And putting it in its own view with its own keybindings (y/n, not j/k) makes the ceremony visible.

Styling: Lipgloss, sparingly

Lipgloss is great and also easy to overuse. We stick to a small palette:

var (
    titleStyle    = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("15")).PaddingLeft(2)
    selectedStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Bold(true).PaddingLeft(2)
    normalStyle   = lipgloss.NewStyle().Foreground(lipgloss.Color("7")).PaddingLeft(2)
    hintStyle     = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).PaddingLeft(2)
    errorStyle    = lipgloss.NewStyle().Foreground(lipgloss.Color("9")).PaddingLeft(2)
    successStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).PaddingLeft(2)
)

ANSI color numbers, not RGB hex. The reason: terminals honor the user's color theme when you use the 0-15 range. A developer who set up a Solarized terminal gets a Solarized-themed catalog. RGB hex overrides that and looks garish on half the themes in the wild.

Things we got wrong, then fixed

  1. We tried to stream results. The first version loaded skills as they came in from the API, one row at a time. It felt responsive for the first two skills and chaotic for the next eighteen. Switched to full-page loads with a spinner.

  2. We used arrow keys only. HN comments on an early preview were vocal: Vim users expect j/k. Now we bind both.

  3. The cursor didn't follow pagination. Already mentioned, but worth repeating: if you don't reset cursor on page load, users get lost.

  4. We rendered the price in floats with no padding. 1.0 credits next to 10.0 credits looks sloppy. We wrote a tiny formatter:

func formatCredits(amount float64) string {
    if amount == float64(int(amount)) {
        return strconv.Itoa(int(amount))
    }
    return strconv.FormatFloat(amount, 'f', 2, 64)
}

Small polish items like this add up. TUIs that feel good have a thousand of them.

Testing TUIs without losing your mind

Bubble Tea programs are pure functions of message plus model to new model plus cmd. That makes them directly unit-testable:

func TestCatalogNavDown(t *testing.T) {
    m := newCatalogListModel(threeSkills(), nilPagination, 0, "", nil, nil)
    next, _ := m.Update(tea.KeyMsg{Type: tea.KeyDown})
    got := next.(catalogListModel)
    if got.cursor != 1 {
        t.Fatalf("expected cursor=1, got %d", got.cursor)
    }
}

We don't test rendering (that's what eyeballs are for), but we test transitions: nav keys move the cursor, / enters search mode, esc returns, purchase confirmation requires two keystrokes. Every bug we ever shipped in the TUI came from an untested state transition. Now those are covered.

The agent angle: why humans still need a TUI

CreateOS is an agent-native platform. The MCP server at https://api-createos.nodeops.network/mcp exposes 75+ tools that AI agents call directly. The marketplace exists primarily so agents can discover tools.

So why bother with a human TUI at all?

Because the developers configuring those agents are still humans, and they need to:

  • Browse the marketplace to decide which Skills to enable for their agent.
  • Scout for underserved categories when they're thinking about what to build and publish.
  • Verify that a purchased Skill is available before telling the agent to use it.
  • Discover Skills that solve a problem they didn't know had a solution.

The TUI solves all four without leaving the shell where those developers already are. And because every TUI command has a non-interactive sibling (createos skills purchased --output json), scripts and agents can consume the same data.

Why Go for a TUI

Quick note because people ask. Go is a great TUI language because:

  • Single-binary distribution. The CLI is one 15 MB binary with no runtime dependencies. Brew install, done.
  • Cross-compile everywhere. macOS Intel, macOS ARM, Linux x86, Linux ARM, Windows. One go build per target.
  • Start time. Go starts in under 20 ms on any laptop. Nothing else in the runtime universe is close.
  • Concurrency fits the model. Bubble Tea commands are goroutines. Go's concurrency primitives are exactly what's needed.

Try it

brew tap nodeops-app/tap
brew install createos
createos login
createos skills catalog

j/k to navigate. / to search. enter for details. b to buy (with confirmation). q to quit.

The full source is at github.com/NodeOps-app/createos-cli/tree/main/internal/ui. Pull requests welcome.


Previous: Building a Monetized API →

Next: The API Skills Economy: How Developers Earn From AI Agents →

Code: GitHub repo · Bubble Tea · Lipgloss · CreateOS MCP

Tags

Bubble teaengineeringGo LanguagemarketplaceTUI

Share

Share on

100,000+ Builders. One Workspace.

Get product updates, builder stories, and early access to features that help you ship faster.

CreateOS is a unified intelligent workspace where ideas move seamlessly from concept to live deployment, eliminating context-switching across tools, infrastructure, and workflows with the opportunity to monetize ideas immediately on the CreateOS Marketplace.