Step-by-step guide to embed Pinocchio’s Bubble Tea + Bobatea terminal chat UI with the extracted tool-loop backend and agent forwarder.
This guide explains how to integrate Pinocchio’s terminal TUI stack into a Go application in a way that is understandable to a brand-new intern. It covers the “moving parts” (Bubble Tea, Bobatea timeline entities, Geppetto events, Watermill routing, Pinocchio backends/forwarders), then walks through a minimal integration recipe you can adapt.
The specific reusable pieces extracted in PI-02 are:
pinocchio/pkg/ui/backends/toolloop/backend.gopinocchio/pkg/ui/forwarders/agent/forwarder.goThis section explains the end-to-end dataflow so you can debug the system without guessing.
In the integrated architecture:
*tea.Program) rendering a Bobatea chat model.Bobatea’s chat model is timeline-centric: it expects backends/forwarders to send messages like:
timeline.UIEntityCreatedtimeline.UIEntityUpdatedtimeline.UIEntityCompletedtimeline.UIEntityDeleted (less common, but supported)These messages create and update renderable entities (assistant text, tool calls, logs, web search results, etc.) in the UI timeline. The chat model then renders them using registered renderer factories.
API reference / anchor files
bobatea/pkg/chat/backend.gobobatea/pkg/timeline/types.goGeppetto engines emit structured events (partial tokens, final text, tool calls/results, logs, etc.). In this architecture, engines publish those events as JSON payloads to a Watermill topic via:
middleware.NewWatermillSink(publisher, topic) → events.EventSinkGuideline: This event stream is primarily for UX/telemetry. Avoid committing durable application state from partial events; use a RunInference boundary (final completion / after RunInference returns) for validation + persistence, then emit final timeline updates for the UI.
API reference / anchor files
geppetto/pkg/events/event-router.gogeppetto/pkg/inference/middleware/sink_watermill.gogeppetto/pkg/events/events.go (look for NewEventFromJson)User types a prompt
↓
Bobatea chat model calls backend.Start(ctx, prompt)
↓
ToolLoopBackend appends a Turn and starts inference (tool loop)
↓
Geppetto emits events → WatermillSink.PublishEvent(...) → topic "chat" (JSON)
↓
EventRouter handler receives Watermill message
↓
agent.MakeUIForwarder(program) decodes events + sends timeline.UIEntity* messages
↓
Bubble Tea program receives messages → Bobatea timeline shell updates → UI redraw
↓
When tool-loop finishes, backend returns BackendFinishedMsg via tea.Cmd to re-enable input
This section defines the terms you’ll see in code, and why they exist.
Bubble Tea program (*tea.Program): The runtime loop that receives messages and re-renders the screen.
tea.NewProgram(model, ...) and start it with p.Run().p.Send(msg) which injects messages into the program from another goroutine.Bubble Tea model (tea.Model): An object with Init(), Update(msg), and View().
Bobatea chat model: The UI component you embed/use as your main Bubble Tea model.
boba_chat.InitialModel(backend, ...).Geppetto event: A structured event emitted by inference as it progresses.
Watermill message: A transport envelope around a payload ([]byte JSON here).
Ack() messages to prevent stalls.Forwarder: A Watermill handler function that:
timeline.UIEntity* messages,program.Send(...).This section shows an end-to-end integration skeleton. It is not a “copy/paste works in every repo” snippet (you still need to decide engine/provider config), but it is structured so you can implement it without needing hidden context.
Pick one Watermill topic for UI events and use it consistently.
simple-chat-agent, the topic is "chat" (see pinocchio/cmd/agents/simple-chat-agent/main.go)."ui" (see pinocchio/pkg/ui/runtime/builder.go).For a new integration, pick one (usually "chat" for agent/tool-loop) and wire:
WatermillSink(topic)EventRouter.AddHandler(topic, ...)Pseudocode:
// Pseudocode imports:
//
// "github.com/go-go-golems/geppetto/pkg/events"
// "github.com/go-go-golems/geppetto/pkg/inference/middleware"
//
router, err := events.NewEventRouter() // defaults to in-memory pub/sub
if err != nil {
return err
}
// All inference events published here:
sink := middleware.NewWatermillSink(router.Publisher, "chat")
events.NewEventRouter() defaults to Watermill’s in-memory gochannel with publish→ACK blocking and no output buffering. In a streaming UI, a single slow handler (UI rendering, disk/DB I/O) can stall inference.
For TUI integrations, prefer explicitly configuring the in-memory pub/sub (or use Redis Streams):
// Pseudocode imports:
//
// "github.com/ThreeDotsLabs/watermill"
// "github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
// "github.com/go-go-golems/geppetto/pkg/events"
// "github.com/go-go-golems/geppetto/pkg/inference/middleware"
//
goPubSub := gochannel.NewGoChannel(gochannel.Config{
OutputChannelBuffer: 256,
BlockPublishUntilSubscriberAck: false,
}, watermill.NopLogger{})
router, err := events.NewEventRouter(
events.WithPublisher(goPubSub),
events.WithSubscriber(goPubSub),
)
if err != nil {
return err
}
sink := middleware.NewWatermillSink(router.Publisher, "chat")
If you want Redis Streams (durable fan-out), use Pinocchio’s Redis helpers:
pinocchio/pkg/redisstream (see pinocchio/cmd/agents/simple-chat-agent/main.go)This depends on your environment (profiles, provider keys, etc.).
At minimum you need:
engine.Enginemiddleware.MiddlewareThe simple-chat-agent example builds these from Glazed sections:
factory.NewEngineFromParsedValues(...)Anchor file:
pinocchio/cmd/agents/simple-chat-agent/main.go (search for mws := []middleware.Middleware{)Tool-loop mode requires a tool registry. If you set reg=nil, you’re effectively “simple chat”.
Pseudocode:
// Pseudocode imports:
//
// "github.com/go-go-golems/geppetto/pkg/inference/tools"
//
registry := tools.NewInMemoryToolRegistry()
// registry.Register(...) // add tools you want
Use the extracted backend:
// Pseudocode imports:
//
// "github.com/go-go-golems/geppetto/pkg/events"
// "github.com/go-go-golems/geppetto/pkg/inference/engine"
// "github.com/go-go-golems/geppetto/pkg/inference/middleware"
// geppettotoolloop "github.com/go-go-golems/geppetto/pkg/inference/toolloop"
// "github.com/go-go-golems/geppetto/pkg/inference/tools"
// toolloopbackend "github.com/go-go-golems/pinocchio/pkg/ui/backends/toolloop"
//
backend := toolloopbackend.NewToolLoopBackend(eng, mws, registry, sink, hook)
Anchor files:
pinocchio/pkg/ui/backends/toolloop/backend.go (backend implementation)pinocchio/cmd/agents/simple-chat-agent/main.go (how it is used)You need at least an LLM text renderer, and you probably want tool/log renderers too.
Pseudocode (pattern):
// Pseudocode imports:
//
// tea "github.com/charmbracelet/bubbletea"
// boba_chat "github.com/go-go-golems/bobatea/pkg/chat"
// "github.com/go-go-golems/bobatea/pkg/timeline"
// renderers "github.com/go-go-golems/bobatea/pkg/timeline/renderers"
// agentmode "github.com/go-go-golems/pinocchio/pkg/middlewares/agentmode"
//
chatModel := boba_chat.InitialModel(backend,
boba_chat.WithTitle("My Agent Chat"),
boba_chat.WithTimelineRegister(func(r *timeline.Registry) {
r.RegisterModelFactory(renderers.NewLLMTextFactory())
r.RegisterModelFactory(renderers.NewToolCallFactory())
r.RegisterModelFactory(renderers.ToolCallResultFactory{})
r.RegisterModelFactory(renderers.LogEventFactory{})
r.RegisterModelFactory(renderers.WebSearchFactory{})
r.RegisterModelFactory(agentmode.AgentModeFactory{})
}),
)
Anchors:
pinocchio/cmd/agents/simple-chat-agent/main.gobobatea/pkg/chat/model.go (look for WithTimelineRegister)// Pseudocode imports:
//
// tea "github.com/charmbracelet/bubbletea"
//
p := tea.NewProgram(chatModel, tea.WithAltScreen())
If you have your own layout (sidebar, overlays, etc.), wrap the chat model with a host model (see pinocchio/cmd/agents/simple-chat-agent/pkg/ui).
This is the “bridge” between inference events and UI updates.
// Pseudocode imports:
//
// agentforwarder "github.com/go-go-golems/pinocchio/pkg/ui/forwarders/agent"
//
router.AddHandler("ui-forward", "chat", agentforwarder.MakeUIForwarder(p))
Important semantic detail:
boba_chat.BackendFinishedMsg{} on provider final/error/interrupt events.BackendFinishedMsg only after the overall loop completes (via the tea.Cmd returned from Start).Pseudocode:
// Pseudocode imports:
//
// "context"
// tea "github.com/charmbracelet/bubbletea"
// "github.com/go-go-golems/geppetto/pkg/events"
// "golang.org/x/sync/errgroup"
//
ctx2, cancel := context.WithCancel(ctx)
defer cancel()
eg, groupCtx := errgroup.WithContext(ctx2)
eg.Go(func() error { return router.Run(groupCtx) })
eg.Go(func() error {
_, err := p.Run()
cancel() // stop router when UI exits
return err
})
return eg.Wait()
This pattern avoids:
This stack has multiple concurrent subsystems (Bubble Tea UI loop, inference goroutines, Watermill router handlers, SQLite writes). A few context-related rules prevent “it worked once but flakes in tmux/CI” failures:
msg.Context() inside Watermill handlers. That context is scoped to message delivery and can be canceled unexpectedly. Use a detached bounded context (context.WithTimeout(context.Background(), ...)) for best-effort persistence.If you do not need tool-loop/agent entities, use the command chatapp/sessionstream path instead of raw Watermill forwarding:
pinocchio/pkg/ui/chatapp_backend.go (ChatAppBackend)pinocchio/pkg/ui/chatapp_fanout.go (ChatAppUIFanout)pinocchio/pkg/chatapp/runner.go| Problem | Likely cause | What to check / do |
|---|---|---|
| UI never updates (blank timeline) | Forwarder not registered or topic mismatch | Confirm the sink topic matches AddHandler topic (e.g. both "chat"). |
| UI updates, but input stays “blurred” | No BackendFinishedMsg ever sent | Confirm backend’s Start returns a tea.Cmd that emits boba_chat.BackendFinishedMsg{} when finished. |
| Tool calls never show up | Missing renderers or missing tool registry | Ensure tool renderer factories are registered; ensure registry != nil and tools are registered. |
| Router handler seems stuck | Watermill messages not ack’d | Forwarder must call msg.Ack(); see pinocchio/pkg/ui/forwarders/agent/forwarder.go. |
| Lots of “unknown event type” logs | Event decoding mismatch | Check events.NewEventFromJson and the event types your engine emits. |
| Streaming stalls / UI freezes | In-memory pub/sub backpressure (publish blocks on handler ACK) | Configure gochannel buffering and disable publish→ACK blocking, or use Redis Streams. |
Persistence flakes with context canceled | Using Watermill message context for DB writes | Persist with a detached bounded context (and serialize SQLite writers). |
pinocchio help chatbuilder-guide (simple chat integration)pinocchio help webchat-debugging-and-ops (debugging patterns that translate well to TUI event flows)pinocchio/pkg/ui/forwarders/agent/forwarder.go