Reference for Pinocchio chatapp protobuf contracts, segment-aware message entities, and shared reasoning/tool-call plugins.
pinocchio/pkg/chatapp is the reusable chat application layer on top of sessionstream. It owns the base chat command, event, UI-event, and timeline schemas, while application packages can add more behavior through ChatPlugin implementations.
The current chatapp contract is protobuf-first:
proto/pinocchio/chatapp/v1/chat.protopkg/chatapp/pb/proto/pinocchio/chatapp/v1buf.chatapp.gen.yamlchatapp.RegisterSchemas(reg, plugins...)Sessionstream still delivers browser frames as JSON over WebSocket, but those frames are the JSON form of registered protobuf messages. Backend code should publish concrete, feature-owned proto.Message payloads, not ad-hoc maps or generic google.protobuf.Struct objects.
Every chatapp/sessionstream command, backend event, UI event, and timeline entity payload must have its own concrete protobuf message, including app-specific features. Treat the protobuf type as the durable API contract between:
Do not register google.protobuf.Struct for event/UI/timeline payloads just because a feature is app-local. App-local still means durable once it is persisted, hydrated, or rendered after reload.
Good:
reg.RegisterUIEvent("ChatAgentModeCommitted", &chatappv1.AgentModeCommittedUpdate{})
reg.RegisterTimelineEntity("AgentMode", &chatappv1.AgentModeEntity{})
Avoid:
reg.RegisterUIEvent("ChatAgentModeCommitted", &structpb.Struct{})
reg.RegisterTimelineEntity("AgentMode", &structpb.Struct{})
Use google.protobuf.Struct only inside a typed message field when the field is intentionally open-ended metadata, for example provider-specific debug details or arbitrary tool input/output. The outer payload registered with sessionstream should still be a named protobuf message.
A repository-level architecture test (pkg/chatapp/schema_policy_test.go) and vet analyzer (pkg/analysis/sessionstreamschema, runnable with make schema-vet) reject RegisterEvent, RegisterUIEvent, and RegisterTimelineEntity registrations that use &structpb.Struct{}.
The base chatapp registers these command messages:
| Name | Protobuf message | Purpose |
|---|---|---|
ChatStartInference | StartInferenceCommand | Start an assistant run for a prompt. |
ChatStopInference | StopInferenceCommand | Stop the active assistant run for a session. |
It registers these backend events and UI events with ChatMessageUpdate payloads:
| Backend event | UI event | Purpose |
|---|---|---|
ChatUserMessageAccepted | ChatMessageAccepted | User prompt was accepted and projected into the timeline. |
ChatInferenceStarted | ChatMessageStarted | Assistant run started. |
ChatTokensDelta | ChatMessageAppended | Assistant text changed during streaming. |
ChatInferenceFinished | ChatMessageFinished | Assistant text segment or final response finished. |
ChatInferenceStopped | ChatMessageStopped | Assistant run stopped or failed. |
It registers one base timeline entity kind:
| Kind | Protobuf message | Purpose |
|---|---|---|
ChatMessage | ChatMessageEntity | User, assistant, thinking, and warning transcript rows. |
ChatMessageUpdate and ChatMessageEntity include segment metadata:
| Field | Meaning |
|---|---|
message_id / JSON messageId | Stable entity ID for this concrete transcript row. |
parent_message_id / JSON parentMessageId | Assistant run ID that owns this row, when the row is a segment. |
segment | One-based segment number within the parent assistant run. |
segment_type / JSON segmentType | Logical segment kind, for example text or thinking. |
final | True only for the final assistant text row of the run. |
This is important for tool loops. A single assistant run can produce:
chat-msg-1:thinking:1
chat-msg-1:text:2
ChatToolCall / ChatToolResult rows
chat-msg-1:thinking:3
chat-msg-1:text:4
Timeline stores and Redux reducers upsert by entity ID. Therefore every distinct transcript row must have a distinct messageId. Do not reuse the parent assistant ID for multiple thinking blocks or for multiple interleaved assistant text blocks.
pkg/chatapp/plugins.NewReasoningPlugin() translates Geppetto reasoning events into chatapp/sessionstream events.
It handles:
*events.EventThinkingPartial*events.EventInfo with thinking-started*events.EventInfo with thinking-endedIt registers concrete ReasoningUpdate protobuf payloads:
| Backend event | UI event | Payload |
|---|---|---|
ChatReasoningStarted | ChatReasoningStarted | ReasoningUpdate |
ChatReasoningDelta | ChatReasoningAppended | ReasoningUpdate |
ChatReasoningFinished | ChatReasoningFinished | ReasoningUpdate |
The payload contains chat-message-shaped fields such as messageId, parentMessageId, segment, role: "thinking", chunk, content, status, and streaming. Each contiguous thinking phase gets a segment ID such as chat-msg-5:thinking:1.
Use this shared plugin instead of defining app-local runtime-debug/reasoning projection code.
pkg/chatapp/plugins.NewToolCallPlugin() translates Geppetto tool lifecycle events into typed protobuf payloads.
It handles:
*events.EventToolCall*events.EventToolCallExecute*events.EventToolResult*events.EventToolCallExecutionResultIt registers:
| Backend event / UI event | Payload |
|---|---|
ChatToolCallStarted | ToolCallUpdate |
ChatToolCallUpdated | ToolCallUpdate |
ChatToolCallFinished | ToolCallUpdate |
ChatToolResultReady | ToolResultUpdate |
It also registers timeline entity kinds:
| Kind | Payload |
|---|---|
ChatToolCall | ToolCallEntity |
ChatToolResult | ToolResultEntity |
Use this shared plugin for apps that want durable, hydrated tool-call and tool-result rows. Product-specific tools can still add their own widgets, but they should not duplicate the generic Geppetto tool lifecycle projection.
A web-chat style application wires the base schemas and plugins at server assembly time:
import (
chatapp "github.com/go-go-golems/pinocchio/pkg/chatapp"
chatplugins "github.com/go-go-golems/pinocchio/pkg/chatapp/plugins"
sessionstream "github.com/go-go-golems/sessionstream/pkg/sessionstream"
)
reg := sessionstream.NewSchemaRegistry()
chatPlugins := []chatapp.ChatPlugin{
myAppSpecificPlugin(),
chatplugins.NewReasoningPlugin(),
chatplugins.NewToolCallPlugin(),
}
if err := chatapp.RegisterSchemas(reg, chatPlugins...); err != nil {
return err
}
engine := chatapp.NewEngine(chatapp.WithPlugins(chatPlugins...))
The reference app uses this pattern from cmd/web-chat/main.go and cmd/web-chat/app/server.go. agentmode remains app-owned under cmd/web-chat; reasoning and tool calls are reusable chatapp plugins under pkg/chatapp/plugins.
The Pinocchio web frontend receives canonical sessionstream frames from /api/chat/ws and maps them into local renderer keys:
ChatMessage snapshot entities become local message entities.message entities with role: "thinking".AgentMode keep their own renderer path.Downstream applications may choose different frontend mappings. The stable contract is the sessionstream event/entity name plus the protobuf payload, not the local React component name.
| Problem | Cause | Solution |
|---|---|---|
| Reasoning appears as one overwritten block | Multiple thinking phases reused the same entity ID | Use ReasoningPlugin and preserve segment-aware messageId values. |
| Tool calls stream live but disappear after reload | Tool calls were only local UI state | Register and use ToolCallPlugin so tool calls project into timeline entities. |
| Protobuf payload cannot be decoded | Schema was not registered with the sessionstream registry | Register base chatapp schemas and every plugin before creating the hub. |
| Frontend sees unknown entity kinds | Backend registered new timeline kinds without frontend renderers | Add a renderer or normalize the entity into an existing local renderer kind. |