UI Messages
UI Messages are the frontend-ready format for displaying agent conversations.
Overview
Helix stores messages in a storage format optimized for persistence and LLM context building. The UI format transforms these for frontend display:
- Merges tool calls with their results
- Provides explicit state tracking
- Omits
role: 'tool'messages (merged into parts)
AI SDK v6 UIMessage Format
Helix uses the Vercel AI SDK v6 UIMessage format for all frontend integrations:
import type { UIMessage } from 'ai';
interface UIMessage {
id: string;
role: 'user' | 'assistant' | 'system';
parts: UIMessagePart[];
metadata?: Record<string, unknown>;
}
type UIMessagePart =
| { type: 'text'; text: string }
| { type: 'reasoning'; text: string }
| {
type: 'dynamic-tool';
toolName: string;
toolCallId: string;
input: Record<string, unknown>;
state: ToolInvocationState;
output?: unknown;
errorText?: string;
};
type ToolInvocationState =
| 'input-streaming'
| 'input-available'
| 'output-available'
| 'output-error';Loading Messages
From State Store
import { loadUIMessages, loadAllUIMessages } from '@helix-agents/ai-sdk';
// Paginated loading
const { messages, hasMore } = await loadUIMessages(stateStore, sessionId, {
offset: 0,
limit: 50,
includeReasoning: true,
includeToolResults: true,
});
// Load all messages (auto-paginates)
const allMessages = await loadAllUIMessages(stateStore, sessionId);Important: When using paginated loading (
loadUIMessages), tool results may not be merged correctly if the tool call and its result span different pages. UseloadAllUIMessageswhen you need guaranteed tool result merging.
Using UIMessageStore Wrapper
For repeated access, wrap your state store:
import { createUIMessageStore } from '@helix-agents/ai-sdk';
const uiStore = createUIMessageStore(stateStore);
const { messages, hasMore } = await uiStore.getUIMessages(sessionId);
const all = await uiStore.getAllUIMessages(sessionId);From FrontendHandler
const { messages, hasMore } = await handler.getMessages(sessionId, {
offset: 0,
limit: 50,
includeReasoning: true,
includeToolResults: true,
});Converting Messages
Storage to AI SDK Format
import { convertToAISDKMessages } from '@helix-agents/ai-sdk';
const uiMessages = convertToAISDKMessages(helixMessages, {
generateId: (index, msg) => `msg-${index}`,
includeReasoning: true,
mergeToolResults: true,
});Tool State Transitions
During Streaming
pending → executing → completed
↘ errorIn Stored Messages
When loading from store, tools are either:
pending(no result saved yet)completed(result available)error(execution failed)
The executing state is transient and only exists during live streaming.
State Mapping
| Internal State | AI SDK State | Description |
|---|---|---|
| pending | input-available | Awaiting execution |
| executing | input-available | Currently running |
| completed | output-available | Finished successfully |
| error | output-error | Execution failed |
Edge Cases
Messages Without Tool Results
When mergeToolResults: false or tool results aren't stored yet:
// Tool part in 'input-available' state without output
{
type: 'dynamic-tool',
toolName: 'search',
toolCallId: 'tc1',
input: { query: 'test' },
state: 'input-available',
// No output field
}Empty Assistant Messages
Assistant messages with only tool calls (no text):
{
id: 'msg-1',
role: 'assistant',
parts: [
{ type: 'dynamic-tool', toolName: 'search', toolCallId: 'tc1', ... }
],
// No text part
}Parallel Tool Calls
Multiple tools called in same message:
{
id: 'msg-1',
role: 'assistant',
parts: [
{ type: 'text', text: 'Let me search and calculate...' },
{ type: 'dynamic-tool', toolName: 'search', state: 'output-available', toolCallId: 'tc1', input: {...}, output: {...} },
{ type: 'dynamic-tool', toolName: 'calculate', state: 'output-available', toolCallId: 'tc2', input: {...}, output: {...} },
],
}Malformed Tool Results
When tool result JSON is invalid, the raw string is preserved:
{
type: 'dynamic-tool',
state: 'output-available',
output: 'invalid json string',
}Orphaned Tool Results
Tool results without matching tool calls are dropped during conversion. This can happen if messages are partially loaded or corrupted.
Hidden Messages (metadata.hidden)
A message with metadata.hidden === true is the canonical mechanism for carrying per-turn context the LLM should see but the chat UI should hide. Typical uses: injecting a current document draft into a writer chat, adding system-derived context (subscription tier, page metadata) the user didn't type, or coordination signals from sub-agents.
The convention is wired end-to-end through Helix:
- Persistence — hidden messages are stored as full messages alongside visible ones. They round-trip through every state-store implementation (Memory, Redis, Postgres, D1) without special handling.
- LLM input —
buildMessagesForLLMdoes NOT filter hidden messages. The LLM sees the full history including hidden context on every turn. This is intentional: it preserves prompt-cache stability and replay determinism. - UI rehydration —
convertToUIMessages(and the higher-levelloadUIMessages/loadAllUIMessages/handler.getMessageshelpers) filter hidden messages by default viafilterHidden: true. Chat UIs never see them on reload.
Default behavior — filterHidden: true
import { loadAllUIMessages } from '@helix-agents/ai-sdk';
// Default — hidden messages are dropped.
const visible = await loadAllUIMessages(stateStore, sessionId);
// → chat UI sees only user/assistant messages the user typed and replies they readEscape hatch — filterHidden: false
// All messages including hidden ones, in source order.
// Useful for debug views, audit UIs, or operator tooling.
const all = await loadAllUIMessages(stateStore, sessionId, {
filterHidden: false,
});The same option is accepted by loadUIMessages, convertToUIMessages, and convertToAISDKMessages.
The COMMON_METADATA_KEYS.HIDDEN constant
The string literal 'hidden' is exported as a typed constant for consumers who want to avoid the magic string:
import { COMMON_METADATA_KEYS } from '@helix-agents/core';
const userInput = {
role: 'user' as const,
content: '<draft>...</draft>',
metadata: { [COMMON_METADATA_KEYS.HIDDEN]: true },
};Injection patterns
There are two places to inject a hidden message — they have different UI implications:
Server-side injection (recommended)
The route handler prepends the hidden UIMessage to messages before calling handleChatStream. The hidden message never enters the client's useChat.messages state, so the chat UI cannot accidentally render it during the live session. On reload, the snapshot path filters it via filterHidden: true.
// app/api/chat/[sessionId]/route.ts
import { COMMON_METADATA_KEYS } from '@helix-agents/core';
import { dispatchChat } from '@/lib/handler';
import { getDraft } from '@/lib/drafts';
export async function POST(req: Request, { params }: { params: { sessionId: string } }) {
const body = await req.json();
const incoming = body.messages ?? [];
const draft = await getDraft(params.sessionId);
// Find the trailing user index and prepend the hidden draft just before it.
let insertAt = incoming.length;
for (let i = incoming.length - 1; i >= 0; i--) {
if (incoming[i].role === 'user') {
insertAt = i;
break;
}
}
const hidden = {
id: `draft-${Date.now()}`,
role: 'user' as const,
parts: [{ type: 'text', text: `<draft>${draft}</draft>` }],
metadata: { [COMMON_METADATA_KEYS.HIDDEN]: true },
};
const messages = [...incoming.slice(0, insertAt), hidden, ...incoming.slice(insertAt)];
return dispatchChat({ sessionId: params.sessionId, messages, request: req });
}This is the cleanest pattern when the context (current draft, page metadata, server-derived state) is available on the server.
Client-side injection
If the context lives only in the browser (e.g., unsaved local draft, in-memory editor state, IndexedDB), inject via useChat.setMessages:
chat.setMessages([
...chat.messages,
{
id: `ctx-${Date.now()}`,
role: 'user',
parts: [{ type: 'text', text: `<draft>${currentDraft}</draft>` }],
metadata: { hidden: true },
},
{ id: 'usr-1', role: 'user', parts: [{ type: 'text', text: userTypedText }] },
]);Live-session UI leak with client-side injection
useChat does NOT filter metadata.hidden on its own — chat.messages contains the hidden entry during the active session, and a naive chat.messages.map(render) will render it. Either filter in your render code, or prefer server-side injection.
{
chat.messages
.filter((m) => m.metadata?.hidden !== true)
.map((m) => <Message key={m.id} message={m} />);
}On reload the snapshot's filterHidden: true drops the hidden message, so the leak is live-session-only — but it's still a sharp edge worth handling.
Multi-message trailing-user run
handleChatStream (and the underlying extractTrailingUserMessages) walks back from the end of messages and forwards the entire run of consecutive trailing user messages to the executor. This is what makes the hidden-context pattern work at the chat-handler boundary — a leading hidden message plus a trailing visible message is one logical "turn" and both flow through together.
See handleChatStream reference for details.
React Integration
import { useChat } from 'ai/react';
import type { UIMessage } from 'ai';
function Chat() {
const { messages } = useChat({ api: '/api/chat' });
return (
<div>
{messages.map((message: UIMessage) => (
<Message key={message.id} message={message} />
))}
</div>
);
}
function Message({ message }: { message: UIMessage }) {
return (
<div className={`message ${message.role}`}>
{message.parts.map((part, i) => (
<Part key={i} part={part} />
))}
</div>
);
}
function Part({ part }) {
if (part.type === 'text') {
return <p>{part.text}</p>;
}
if (part.type === 'reasoning') {
return (
<details>
<summary>Thinking</summary>
<pre>{part.text}</pre>
</details>
);
}
if (part.type === 'dynamic-tool') {
return <ToolPart part={part} />;
}
return null;
}
function ToolPart({ part }) {
return (
<div className={`tool ${part.state}`}>
<strong>{part.toolName}</strong>
<pre>{JSON.stringify(part.input, null, 2)}</pre>
{part.state === 'output-available' && (
<div className="output">
<pre>{JSON.stringify(part.output, null, 2)}</pre>
</div>
)}
{part.state === 'output-error' && <div className="error">{part.errorText}</div>}
</div>
);
}Loading Existing Conversations
import { loadAllUIMessages } from '@helix-agents/ai-sdk';
function ChatPage({ sessionId }) {
const [initialMessages, setInitialMessages] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadAllUIMessages(stateStore, sessionId).then((messages) => {
setInitialMessages(messages);
setLoading(false);
});
}, [sessionId]);
const { messages } = useChat({
api: '/api/chat',
initialMessages,
});
if (loading) return <Spinner />;
return (
<div>
{messages.map((m) => (
<Message key={m.id} message={m} />
))}
</div>
);
}API Reference
Loading Functions
| Function | Returns |
|---|---|
loadUIMessages() | { messages: UIMessage[], hasMore: boolean } |
loadAllUIMessages() | UIMessage[] |
Conversion Function
| Function | Description |
|---|---|
convertToAISDKMessages(messages, options) | Convert Helix Message[] to AI SDK UIMessage[] |
Options
interface LoadUIMessagesOptions {
offset?: number; // Starting position (default: 0)
limit?: number; // Max messages to return (default: 50)
includeReasoning?: boolean; // Include thinking content (default: true)
includeToolResults?: boolean; // Merge tool results (default: true)
filterHidden?: boolean; // Drop messages with metadata.hidden:true (default: true)
generateId?: (index: number, message: Message) => string;
}See Hidden Messages for the lifecycle.
Next Steps
- AI SDK Package - Full package reference
- React Integration - Building chat UIs
- Streaming - Real-time updates