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. |
StartInferenceCommand carries the prompt text plus repeated ChatAttachment attachments
(images referenced by URL, never bytes). A submission may consist of attachments
only; Service.SubmitPromptRequest rejects a request only when both the prompt
and the attachments are empty. Attachments are echoed in ChatUserMessageAccepted
and projected into ChatMessageEntity.attachments, and image attachments are
appended to the geppetto user block via session.AppendNewTurnFromUserMessage
(see chatapp.AttachmentsToTurnImages). Applications that own an upload endpoint
resolve serverkit.SubmitMessageRequest.Attachments (ids) into chatapp.Attachment
values; Attachment.Metadata["turn_url"] may override the URL placed into the turn
when the runtime resolves images itself.
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. |