Step-by-step playbook for building a sessionstream-backed React chat app with Go handlers, Geppetto inference, hydration, websockets, and custom widgets.
This tutorial explains how to build an application shaped like cmd/web-chat without copying the old historical pkg/evtstream layout. The goal is not merely to get a chat box on the screen. The goal is to build a maintainable session-based chat application with a clear ownership model: sessionstream owns the event-streaming substrate and protobuf transport, pinocchio and geppetto own runtime composition and inference machinery, and your application owns product-specific HTTP contracts, feature extensions, widgets, and frontend state.
By the end, you should understand where each piece belongs, how a prompt moves from React to Go to Geppetto and back again, how hydrated snapshots and live UI events fit together, and how to add custom widgets without polluting the shared substrate.
You are building a system with this shape:
React UI
-> HTTP create/submit APIs
-> WebSocket subscribe stream
-> app-owned Go server
-> sessionstream Hub + projections + hydration
-> pinocchio chat package + app-owned features
-> geppetto engine + runtime middlewares + model providers
The important design decision is that the canonical internal truth is the backend event stream, not the React component tree and not a frontend-only message array. React renders what the backend has already turned into snapshot entities and live UI events.
Use this pattern when you want all of the following:
If you only need a quick demo with no reconnect semantics and no app-owned extensions, this architecture may be heavier than necessary. It becomes worth it when the application must survive reloads, support custom events, and remain understandable after multiple feature additions.
Start here, because most mistakes in this space are ownership mistakes.
sessionstream ownsHub, command routing, event publishing, projection plumbing,ClientFrame / ServerFrame websocket transport with role-specific ordinals.pinocchio / geppetto ownThat last bullet is the one to keep repeating to yourself. If your application wants a custom card, custom event, or custom mode switch widget, the application should own it.
Read these files before starting implementation:
sessionstream/doc.gosessionstream/hub.gosessionstream/projection.gosessionstream/hydration.gosessionstream/transport/ws/server.gopinocchio/pkg/chatapp/chat.gopinocchio/pkg/chatapp/service.gopinocchio/pkg/chatapp/features.gopinocchio/pkg/chatapp/plugins/reasoning.gopinocchio/pkg/chatapp/plugins/toolcall.goproto/pinocchio/chatapp/v1/chat.protopinocchio/cmd/web-chat/app/server.gopinocchio/cmd/web-chat/main.gopinocchio/cmd/web-chat/agentmode_chat_feature.gopinocchio/cmd/web-chat/agentmode_chat_feature_test.gopinocchio/pkg/middlewares/agentmode/middleware.gopinocchio/pkg/middlewares/agentmode/preview_event.gopinocchio/cmd/web-chat/web/src/ws/wsManager.tspinocchio/cmd/web-chat/web/src/ws/wsManager.test.tspinocchio/cmd/web-chat/web/src/webchat/rendererRegistry.tspinocchio/cmd/web-chat/web/src/webchat/cards.tsxpinocchio/cmd/web-chat/web/src/webchat/ChatWidget.tsxHere is the end-to-end flow you should be aiming for.
1. React submits a message
-> POST /api/chat/sessions/:id/messages
2. App server resolves runtime/profile
-> pinocchio runtime composer
-> geppetto engine + middleware chain
3. App service submits a command to sessionstream
-> ChatStartInference
4. Chat handler publishes canonical backend events
-> ChatUserMessageAccepted
-> ChatInferenceStarted
-> ChatTokensDelta
-> ChatInferenceFinished
-> app-owned feature events when applicable
5. sessionstream projections derive outputs
-> TimelineEntity records for hydration
-> UIEvent frames for live delivery
6. WebSocket transport fans UI events to subscribers
-> snapshot first
-> live UI events after subscribe
7. React updates state from snapshot + UI events
-> timeline entities map to cards/messages/widgets
The virtue of this model is that the frontend does not invent truth. It renders truth derived by the backend.
A practical layout for a new app looks like this:
cmd/my-chat-app/
main.go
app/
server.go
contracts.go
runtime.go
features/
myfeature.go
myfeature_test.go
web/
src/
ws/
features/
webchat/
store/
App.tsx
pkg/mychatapp/
chat.go
service.go
features.go
A useful rule is:
pkg/mychatapp owns reusable app-grade chat behavior for this one product family,cmd/my-chat-app owns delivery, wiring, and product-specific features,sessionstream stays framework-grade and unaware of your product.Before writing handlers, define what the browser talks to.
A minimal contract looks like this:
POST /api/chat/sessions
POST /api/chat/sessions/:sessionId/messages
GET /api/chat/sessions/:sessionId
WS /api/chat/ws
Why define this first? Because otherwise the backend and frontend drift into ad hoc assumptions. The app server should be the place where browser-facing payloads are stabilized.
A minimal contracts.go usually contains shapes like:
type CreateSessionRequest struct {
Profile string `json:"profile,omitempty"`
Registry string `json:"registry,omitempty"`
}
type SubmitMessageRequest struct {
Prompt string `json:"prompt"`
Profile string `json:"profile,omitempty"`
Registry string `json:"registry,omitempty"`
IdempotencyKey string `json:"idempotencyKey,omitempty"`
}
type SessionSnapshotResponse struct {
SessionID string `json:"sessionId"`
SnapshotOrdinal string `json:"snapshotOrdinal"`
Status string `json:"status,omitempty"`
Entities []SnapshotEntity `json:"entities"`
}
The browser should not know about Geppetto turn internals, middlewarecfg details, or storage internals. It should know about sessions, messages, snapshots, and live events.
Your downstream chat package is where you turn generic sessionstream primitives into a chat-shaped application surface.
The reference pattern is pinocchio/pkg/chatapp.
agentmode cards.type Service struct {
hub *sessionstream.Hub
engine *Engine
}
type PromptRequest struct {
Prompt string
IdempotencyKey string
Runtime *infruntime.ComposedRuntime
}
This package should give callers domain methods like SubmitPromptRequest, Stop, WaitIdle, and Snapshot instead of making every caller work directly with raw command names.
Do this early. If you wait until the third or fourth app-owned widget, you will end up shoving app-specific logic into your base chat package.
The reference seam is pinocchio/pkg/chatapp/features.go.
type ChatPlugin interface {
RegisterSchemas(reg *sessionstream.SchemaRegistry) error
HandleRuntimeEvent(ctx context.Context, runtime RuntimeEventContext, event gepevents.Event) (bool, error)
ProjectUI(ctx context.Context, ev sessionstream.Event, session *sessionstream.Session, view sessionstream.TimelineView) ([]sessionstream.UIEvent, bool, error)
ProjectTimeline(ctx context.Context, ev sessionstream.Event, session *sessionstream.Session, view sessionstream.TimelineView) ([]sessionstream.TimelineEntity, bool, error)
}
This seam gives you three critical powers:
sessionstream,That is the mechanism that makes custom widgets clean instead of invasive. For common chat behaviors, prefer the shared plugins under pinocchio/pkg/chatapp/plugins: NewReasoningPlugin() for thinking/reasoning streams and NewToolCallPlugin() for generic Geppetto tool lifecycle rows.
Your app server should compose the substrate, the downstream chat package, and any app-owned feature sets.
The reference is pinocchio/cmd/web-chat/app/server.go.
SchemaRegistry,sessionstream.Hub,reg := sessionstream.NewSchemaRegistry()
_ = mychatapp.RegisterSchemas(reg, myFeatures...)
store := storesqlite.New(...)
ws := wstransport.NewServer(snapshotProvider)
hub, _ := sessionstream.NewHub(
sessionstream.WithSchemaRegistry(reg),
sessionstream.WithHydrationStore(store),
sessionstream.WithUIFanout(ws),
)
engine := mychatapp.NewEngine(
mychatapp.WithPlugins(myFeatures...),
)
_ = mychatapp.Install(hub, engine)
svc, _ := mychatapp.NewService(hub, engine)
The app server is where browser delivery and runtime resolution meet. It is not where shared framework abstractions should be invented.
A real chat app needs more than demo inference. It needs profile selection, middleware composition, provider settings, and runtime fingerprinting.
In Pinocchio, the reference pieces are:
pinocchio/cmd/web-chat/canonical_runtime_resolver.gopinocchio/cmd/web-chat/runtime_composer.gopinocchio/cmd/web-chat/profiles/*The key point is that your app server accepts a RuntimeResolver interface, and the app owns how a browser request becomes a composed Geppetto runtime.
That separation matters because sessionstream should not know what a profile registry is, what agentmode means, or how your application chooses among runtime stacks.
If you postpone hydration, your frontend will silently become the truth source. That creates pain later.
A robust application should support:
Use the existing sessionstream stores:
sessionstream/hydration/memorysessionstream/hydration/sqliteStart with memory if you must, but keep the store seam active from day one.
The frontend should merge:
snapshot frames with snapshotOrdinal,ui-event frames with eventOrdinal,The reference websocket client is pinocchio/cmd/web-chat/web/src/ws/wsManager.ts. Browser frames are JSON, but backend commands, events, UI events, and timeline entities are registered protobuf messages. Preserve createdOrdinal and lastEventOrdinal metadata when you need deterministic hydrated ordering.
The key frontend rules are:
sessionId,role, content, status, and streaming,A good mental model is:
snapshot entity / ui event
-> normalized timeline mutation
-> store update
-> renderer registry lookup
-> React component
Do not let raw websocket frames leak all the way into React components.
This is the part most people get wrong.
If you want a custom widget, start on the backend.
runtime middleware event
-> app-owned feature HandleRuntimeEvent(...)
-> app backend event
-> timeline/UI projection
-> hydrated entity + UI event
-> frontend renderer
middleware does something
-> frontend invents a local card shape from an unrelated text stream
The correct pattern is more work upfront, but it gives you durable state, reconnect correctness, and testable semantics.
agentmodeStudy:
pinocchio/pkg/chatapp/plugins/reasoning.gopinocchio/pkg/chatapp/plugins/toolcall.gopinocchio/cmd/web-chat/agentmode_chat_feature.gopinocchio/pkg/middlewares/agentmode/*pinocchio/cmd/web-chat/web/src/webchat/cards.tsxWhat these features show:
pkg/chatapp/plugins,agentmode stay in the app package,A custom widget is not just a React component. It is a contract between backend and frontend.
Your feature should emit a registered timeline entity kind such as:
AgentMode
ChatToolCall
ChatToolResult
ResearchPlan
ApprovalGate
Define a stable, concrete protobuf payload for every command, event, UI event, and timeline entity you register, even when the feature is app-specific. App-specific payloads are still durable contracts once they are stored in snapshots or consumed by frontend renderers.
Use google.protobuf.Struct only inside a typed message field for intentionally open-ended metadata. Do not register &structpb.Struct{} as the top-level payload for a sessionstream event, UI event, or timeline entity.
Register a renderer keyed by entity kind.
rendererRegistry.register("AgentMode", AgentModeCard)
The renderer should consume normalized entity payloads, not raw websocket envelopes.
This gives you a powerful property: the same entity can appear after reload from snapshot or live during the current session, and the renderer does not care which path produced it.
Do not leave this architecture untested. The whole benefit of this shape is that each layer has a clean seam.
Test:
Reference:
pinocchio/pkg/chatapp/chat_test.gopinocchio/pkg/chatapp/service_test.goTest:
Reference:
pinocchio/pkg/chatapp/plugins/reasoning_test.gopinocchio/pkg/chatapp/plugins/toolcall_test.gopinocchio/cmd/web-chat/agentmode_chat_feature_test.goTest:
Reference:
pinocchio/cmd/web-chat/app/server_test.goTest:
Reference:
pinocchio/cmd/web-chat/web/src/ws/wsManager.test.tsIf you are building a new app from scratch, do it in this order:
sessionstream,That order prevents you from building a beautiful frontend on top of a backend that still lacks durable truth.
The point of this architecture is not that it prevents all mistakes. It prevents some classes of mistakes if you notice them early.
sessionstreamThis usually starts as “just one convenience helper.” It ends with the framework knowing about your product.
Fix: move the logic into the downstream chat package or app-owned feature file.
This feels fast until reload/reconnect happens.
Fix: give the widget a backend event and timeline entity first.
This makes your supposedly reusable app-grade package harder to reason about and harder to reuse across sibling applications.
Fix: keep the feature seam generic and implement concrete features in app-owned code.
Then the frontend behaves differently on reload than during live streaming.
Fix: normalize both paths through one mapping layer and write explicit tests.
Before calling your app “done,” make sure you can say yes to all of these:
sessionstream.Hub instead of inventing its own event bus?If any of those answers is no, the application may still work, but it probably does not yet have the durability or extensibility this playbook is aiming for.
| Problem | Cause | Solution |
|---|---|---|
| User messages appear live but disappear on reload | Frontend inserted optimistic state but backend never projected a durable user entity | Publish a backend ...UserMessageAccepted event and persist it through timeline projection |
| Custom widget appears only during streaming | Widget state is frontend-only or emitted as UI-only event | Add an app-owned backend event and timeline entity kind |
| Middleware event never shows in UI | Runtime event is emitted but no app-owned feature translates it | Implement HandleRuntimeEvent(...) in a feature set and register it with the app server |
| Reload restores messages but not widget state | Snapshot encoding does not include the custom entity kind | Register schemas and timeline projection for the feature entity |
| Websocket subscribes but no initial state appears | Snapshot provider or hydration store is missing/incorrect | Verify transport/ws is built with a working snapshot provider backed by the same store used by the Hub |
| Frontend works in dev but breaks after restart | State depends on transient UI events instead of hydrated entities | Make snapshot entities sufficient to rebuild the page |
| Feature logic keeps creeping into the chat core | No generic feature seam exists | Add a ChatPlugin interface like pinocchio/pkg/chatapp/features.go and move product features out |
webchat-getting-started — quick local run workflow for the existing reference app
webchat-backend-reference — current backend contract details
webchat-frontend-architecture — current React-side structure
chatapp-protobuf-plugins — protobuf payloads and shared chatapp plugins
webchat-frontend-integration — frontend/backend event and rendering model
sessionstream/cmd/sessionstream-systemlab — framework-oriented lab examples and chapters in the extracted framework repo