Step-by-step guide to creating a Geppetto middleware that emits UI events and a Bubble Tea renderer to display them in the Pinocchio timeline.
This tutorial walks you through building a complete UI feature powered by Geppetto middlewares and Bubble Tea renderers in Pinocchio. You will implement a middleware that detects a condition during inference and emits a structured event, build a matching renderer that displays the event in the bobatea/pkg/timeline, and wire the whole system together using the tool_loop_backend.go forwarder. The end result is an end-to-end, event-driven UI element (like agentmode) that can be toggled interactively.
For background, read:
glaze help geppetto-inference-engines (reference: geppetto/pkg/doc/topics/06-inference-engines.md)glaze help geppetto-middlewares (reference: geppetto/pkg/doc/topics/09-middlewares.md)We’ll create a simple “mode switch” feature inspired by agentmode:
EventAgentModeSwitch with metadata and optional analysis text.The flow is event-driven and append-only:
*turns.Turn and publish events via a Watermill sink.tool_loop_backend.go) consumes those events and emits timeline.UIEntity* messages into the Bubble Tea program.timeline.Controller creates entity models (renderers) based on RendererDescriptor.Kind.Middlewares wrap RunInference(ctx, *turns.Turn) to add cross-cutting behavior. We’ll define a middleware that decides when to switch “mode” and publishes a structured event. The publishing happens through an event sink attached to the engine/context. See glaze help geppetto-middlewares for the core interfaces and composition rules.
package agentmode
import (
"context"
"time"
"github.com/go-go-golems/geppetto/pkg/events"
"github.com/go-go-golems/geppetto/pkg/inference/middleware"
"github.com/go-go-golems/geppetto/pkg/turns"
"github.com/pkg/errors"
)
// ModeSwitch contains the information we want to show in the UI.
type ModeSwitch struct {
From string
To string
Analysis string
}
// NewMiddleware returns a Middleware that detects mode switches and emits a UI-friendly event.
func NewMiddleware() middleware.Middleware {
return func(next middleware.HandlerFunc) middleware.HandlerFunc {
return func(ctx context.Context, t *turns.Turn) (*turns.Turn, error) {
// Run downstream first (or do pre-processing before as needed)
updated, err := next(ctx, t)
if err != nil {
return updated, err
}
// Detect a mode decision (this is domain-specific; replace with your logic)
ms := detectModeSwitch(updated)
if ms != nil {
// Publish a structured event for the UI
e := &events.EventAgentModeSwitch{
Message: "Agent mode changed",
Data: map[string]any{
"from": ms.From,
"to": ms.To,
"analysis": ms.Analysis,
"ts": time.Now().Format(time.RFC3339),
},
}
// Retrieve sinks from context and publish
sinks := events.GetEventSinks(ctx)
for _, s := range sinks {
if err := s.PublishEvent("chat", e); err != nil {
return updated, errors.Wrap(err, "publish mode switch event")
}
}
}
return updated, nil
}
}
}
func detectModeSwitch(t *turns.Turn) *ModeSwitch {
// Replace with real detection logic (e.g., inspect blocks or Turn.Data)
return nil
}
Attach this middleware when constructing the engine wrapper (see “Composition” in glaze help geppetto-middlewares).
mws := []middleware.Middleware{agentmode.NewMiddleware()}
Renderers implement the EntityModel interface from bobatea/pkg/timeline/renderers. They receive properties from UIEntityCreated.Props and can update their internal state in response to selection and key events. Use TAB to toggle details.
Key points for interactive models:
showDetails.timeline.EntityUnselectedMsg.tea.KeyMsg when key.String() is "tab" or "shift+tab" and the model is selected.package agentmode
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/go-go-golems/bobatea/pkg/timeline"
)
type AgentModeModel struct {
title string
from string
to string
analysis string
selected bool
showDetails bool
}
func (m *AgentModeModel) Init() tea.Cmd { return nil }
func (m *AgentModeModel) OnProps(props map[string]any) tea.Cmd {
if v, ok := props["title"].(string); ok { m.title = v }
if v, ok := props["from"].(string); ok { m.from = v }
if v, ok := props["to"].(string); ok { m.to = v }
if v, ok := props["analysis"].(string); ok { m.analysis = v }
return nil
}
func (m *AgentModeModel) Update(msg tea.Msg) (timeline.EntityModel, tea.Cmd) {
switch msg := msg.(type) {
case timeline.EntitySelectedMsg:
m.selected = true
case timeline.EntityUnselectedMsg:
m.selected = false
m.showDetails = false
case tea.KeyMsg:
if m.selected && (msg.String() == "tab" || msg.String() == "shift+tab") {
m.showDetails = !m.showDetails
}
}
return m, nil
}
func (m *AgentModeModel) View() string {
head := fmt.Sprintf("Agent Mode: %s → %s", m.from, m.to)
if m.title != "" {
head = fmt.Sprintf("%s — %s", head, m.title)
}
if m.showDetails && m.analysis != "" {
return head + "\n\n" + m.analysis
}
return head
}
Register the renderer with the timeline controller using a factory:
type AgentModeFactory struct{}
func (AgentModeFactory) Kind() string { return "agent_mode" }
func (AgentModeFactory) New() timeline.EntityModel { return &AgentModeModel{} }
In your app wiring (for example in main.go), register it:
shell := timeline.NewShell()
shell.Controller().RegisterModelFactory(agentmode.AgentModeFactory{})
Your backend converts engine/middleware events into UI messages for the Bubble Tea program. Pinocchio includes ToolLoopBackend which provides MakeUIForwarder(p *tea.Program), a Watermill handler that parses Geppetto events and emits timeline.UIEntity* messages. See pinocchio/cmd/agents/simple-chat-agent/pkg/backend/tool_loop_backend.go.
Add/ensure a case for your event (e.g., EventAgentModeSwitch) that creates a timeline entity and completes it:
case *events.EventAgentModeSwitch:
props := map[string]any{"title": e_.Message}
for k, v := range e_.Data { props[k] = v }
localID := fmt.Sprintf("agentmode-%s-%d", md.TurnID, time.Now().UnixNano())
p.Send(timeline.UIEntityCreated{
ID: timeline.EntityID{LocalID: localID, Kind: "agent_mode"},
Renderer: timeline.RendererDescriptor{Kind: "agent_mode"},
Props: props,
})
p.Send(timeline.UIEntityCompleted{ID: timeline.EntityID{LocalID: localID, Kind: "agent_mode"}})
Notes:
LocalIDs to avoid collisions.UIEntityUpdated and finalize with UIEntityCompleted.timeline.UIEntityDeleted{ID: ...}.Putting it all together in a main.go-style setup:
// 1) Create event router + sink (for streaming)
router, _ := events.NewEventRouter()
watermillSink := middleware.NewWatermillSink(router.Publisher, "chat")
// 2) Create engine and wrap with our middleware
baseEngine, _ := factory.NewEngineFromParsedLayers(parsed)
mws := []middleware.Middleware{agentmode.NewMiddleware()}
// 3) Build Bubble Tea program with timeline shell and register renderer
shell := timeline.NewShell()
shell.Controller().RegisterModelFactory(agentmode.AgentModeFactory{})
p := tea.NewProgram(shell)
// 4) Forward events from router to UI
backend := backendpkg.NewToolLoopBackend(baseEngine, mws, registry, watermillSink, nil)
router.AddHandler("chat-ui", "chat", backend.MakeUIForwarder(p))
// 5) Run router and TUI concurrently, then start the backend
go router.Run(context.Background())
go p.Run()
// Start the tool loop (which will emit events processed by our middleware)
cmd, _ := backend.Start(context.Background(), "What is 2+2?")
p.Send(cmd())
View() pure; the shell updates the viewport content without auto-jumping to the bottom after interactive toggles.Turn.Data.showDetails.UIEntityDeleted if needed.glaze help geppetto-inference-enginesglaze help geppetto-middlewarespinocchio/pkg/middlewares/agentmode/agent_mode_model.gopinocchio/pkg/middlewares/agentmode/middleware.gopinocchio/cmd/agents/simple-chat-agent/pkg/backend/tool_loop_backend.go