Message Protocol
Store a response and show it again later, with form values kept, using buildMessage and parseMessage.
The message protocol lets your app store a model response and show it again later, with the user's form values kept. Without stored context, a reloaded chat has no form values to restore.
For example, a user fills half of a booking form in a chat and closes the tab. When the user opens the chat again, the form is back with the same dates and guest count. Your app stored the response and the form values together as one message.
Three parties handle a stored message:
- Your app writes it.
buildMessagewraps the response text and the context, such as form values, in marker lines that start with]]>openui:. - The Renderer reads it. Pass the stored message as
response. The Renderer renders the content and ignores the context. - The model never sees the markers. Before a stored message goes back to the model as history, your app strips the markers with
parseMessage.
Both functions are exported from @openuidev/react-lang and @openuidev/lang-core.
Write a message
import { buildMessage } from "@openuidev/react-lang";
const stored = buildMessage({
content: responseText,
context: { formState: { note: "rush" } },
attributes: { library: "support@1.2.0" },
end: true,
});| Field | Description |
|---|---|
content | The response text. |
context | Optional. A string is written as is. Any other value is written as JSON. null or undefined writes no context section. |
attributes | Optional. Written after ? on the content line, encoded like URLSearchParams (@ becomes %40). |
end | Optional. true adds the end line. Set it when the stream has finished. |
What the context holds is up to your app. OpenUI does not require a shape.
Read a message
import { parseMessage } from "@openuidev/react-lang";
const { content, context, attributes, end } = parseMessage(stored);
// content: "root = Card([title, saveBtn])\n..."
// context: { formState: { note: "rush" } }
// attributes: { library: "support@1.2.0" }
// end: true| Field | Description |
|---|---|
content | The body of the content section. For text with no markers, the whole text. |
context | The body of the context section, parsed as JSON. Raw text when it is not valid JSON. null when there is no context section. |
attributes | The decoded attributes of the content line. |
end | true when the message has an end line. |
end is absent when the host did not mark the stream as finished. A message without it still renders.
Show a stored message again
The Renderer accepts a stored message as response. It removes the marker lines and renders the content:
<Renderer library={library} response={stored} />The Renderer does not read the context. To restore form values, read them yourself and pass them as initialState. To keep them up to date, write a new message from onStateUpdate:
import { buildMessage, parseMessage, Renderer, type Library } from "@openuidev/react-lang";
type Context = { formState?: Record<string, unknown> } | null;
export function StoredMessage({
stored,
library,
save,
}: {
stored: string;
library: Library;
save: (message: string) => void;
}) {
const { content, context, attributes } = parseMessage(stored);
return (
<Renderer
library={library}
response={stored}
initialState={(context as Context)?.formState}
onStateUpdate={(formState) =>
save(buildMessage({ content, context: { formState }, attributes, end: true }))
}
/>
);
}Send history to the model
Strip the markers before a stored message goes back to the model. For an assistant turn, send parseMessage(stored).content. If your app sends context to the model (form values, click data), turn it into plain text in the user turn. The model must never see ]]>openui: lines.
Complete example
The StoredMessage component above is the whole client side. Your app also:
- Saves each finished response:
save(buildMessage({ content: responseText, end: true })). - Renders it with
<StoredMessage stored={row.message} library={openuiLibrary} save={...} />, wheresavewrites the new message back to the same row. - Sends
parseMessage(row.message).contentas the assistant turn when it builds the history for the next request.
The user fills in a form, reloads the page, and sees the form with the same values. The model sees only the program, never a ]]>openui: line.
When to use it
Use the message format when:
- Your app stores chat history and shows generated UI again after a reload.
- Form values or click context should survive a reload.
Do not use it when:
- Responses are shown once and never stored. Pass the raw text to the
Renderer. - You use
AgentInterfacefrom@openuidev/react-ui. It already stores messages in this format. See The react-ui chat format.
Format
A stored message is the response text with marker lines:
]]>openui:content?library=support%401.2.0
root = Card([title, saveBtn])
title = TextContent("Order 42")
saveBtn = Button("Save", @ToAssistant("Save order", {orderId: 42}))
]]>openui:context
{"formState":{"note":"rush"}}
]]>openui:end| Line | Meaning |
|---|---|
]]>openui:content | Starts the content: the program, or prose with fenced programs. Attributes follow ?. |
]]>openui:context | Starts the context: data your app keeps with the message, such as form values. Usually JSON. |
]]>openui:end | The stream finished. It is absent when the host did not mark the stream as finished. |
Reading rules:
- A
]]>openui:contentmarker in the middle of a line is first moved onto its own line. Other markers count only at the start of a line, so]]>openui:endin the middle of a line is ordinary text. - With several content sections, the last one wins. A context section before it is ignored.
- Text before the first marker is the content only when the message has no content marker.
- Unknown marker kinds are skipped together with their text.
- Plain text with no markers comes back unchanged as
content, so older stored responses still read.
While streaming
Pass { streaming: true } while the response is still arriving:
const { content } = parseMessage(partialText, { streaming: true });A last line that is, or could become, a marker line (for example ]]>open) is then held back until its newline arrives, so a half-written marker never shows up as content.
The react-ui chat format
AgentInterface in @openuidev/react-ui stores its messages in this format:
- An assistant message stores
[formState]as its context once the user changes a field. - A user turn created by
@ToAssistantstores the message as content and an array as context:["User clicked: <message>", formState, context]. The second element is the form state. It is present when there is form state or a context, and is{}when there is only a context. The third element, the@ToAssistantcontext, is present only when one is given.
It still reads messages stored in the older <content>...</content><context>...</context> envelope.