Skip to content

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:

typescript
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 ​

typescript
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. Use loadAllUIMessages when you need guaranteed tool result merging.

Using UIMessageStore Wrapper ​

For repeated access, wrap your state store:

typescript
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 the chat handler helpers ​

typescript
import { getUIMessages } from '@helix-agents/ai-sdk';

const { messages, hasMore } = await getUIMessages(
  { stateStore },
  { sessionId, offset: 0, limit: 50, includeThinking: true }
);

// Cloudflare DO: the same loader on the chat-handler factory.
const page = await chat.getMessages({ sessionId, offset: 0, limit: 50 });

For a page that also resumes a live run, load a snapshot instead (buildSnapshot): its messages are the committed prefix that matches its stream cursor. See Resumable Streams.

Converting Messages ​

Storage to AI SDK Format ​

typescript
import { convertToAISDKMessages } from '@helix-agents/ai-sdk';

const uiMessages = convertToAISDKMessages(helixMessages, {
  generateId: (index, msg) => `msg-${index}`,
  includeReasoning: true,
  mergeToolResults: true,
});

Message ids ​

UI message ids are derived from row positions, so the snapshot, the live stream and the committed message agree on the id of every message.

  • Every row that is not a role: 'tool' row and not hidden (metadata.hidden) gets a row index from a running counter, starting at 0.
  • Consecutive assistant rows form one UI message. Tool rows between them do not break the run. Every user or system row is its own UI message.
  • A UI message's id is generateId(index of its first row). The default generateId is (index) => `msg-${index}`.

So ids are not contiguous. A user message, an assistant step that calls a tool, its tool result, a second assistant step and the next user message convert to:

typescript
// rows:  user, assistant, tool, assistant, user
// index: 0     1          -     2          3
[
  { id: 'msg-0', role: 'user' /* ... */ },
  { id: 'msg-1', role: 'assistant' /* both assistant rows, merged */ },
  { id: 'msg-3', role: 'user' /* ... */ },
];

Hidden rows take no index, so hiding a message never shifts the ids around it. With filterHidden: false the hidden rows are emitted and the counter instead counts every emitted message, so ids differ from the default conversion; use it only for debug or audit views. Do not key durable data on msg-<n> ids: an upgrade that changes the rule moves them (see the upgrade guide).

The run's response id: startUIMessageCount ​

Each run records startUIMessageCount in its run-start commit: the first-row index of the UI message that will hold the run's response (core runBoundaryFrom / responseIndexFor, computed over the session's rows plus the run's input rows).

  • When the conversation ends with an assistant group (a client-tool or approval continuation), it is that group's first-row index: the response streams into the open assistant message.
  • Otherwise it is the next row index: the response is a new UI message.

The response id is generateId(startUIMessageCount). A live snapshot reports it as resumeMessageId, the live stream's start event carries it as messageId, and the converter gives the committed message the same id. That three-way agreement is what keeps useChat from rendering a duplicate bubble, or dropping one, across refresh, resume and commit.

Replay markers are transient ​

When a client resumes with the replay prefix, the server replays the in-flight message's committed parts between data-helix-replay { phase: 'begin' | 'end', messageId } markers. They are sent with transient: true, so the AI SDK delivers them to onData and never adds them to message.parts. useHelixChat uses them to keep replayed tool calls from running again. The resync triggers (data-stream-resync, data-attempt-superseded, data-tool-output-elsewhere) are not transient: they stay in parts, where collectResyncTriggers(messages) finds them.

Tool State Transitions ​

During Streaming ​

pending → executing → completed
                   ↘ error

In 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 StateAI SDK StateDescription
pendinginput-availableAwaiting execution
executinginput-availableCurrently running
completedoutput-availableFinished successfully
erroroutput-errorExecution failed

Edge Cases ​

Messages Without Tool Results ​

When mergeToolResults: false or tool results aren't stored yet:

typescript
// 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):

typescript
{
  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:

typescript
{
  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:

typescript
{
  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:

  1. 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.
  2. LLM input — buildMessagesForLLM does 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.
  3. UI rehydration — convertToUIMessages (and the higher-level loadUIMessages / loadAllUIMessages / handler.getMessages helpers) filter hidden messages by default via filterHidden: true. Chat UIs never see them on reload.

Default behavior — filterHidden: true ​

typescript
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 read

Escape hatch — filterHidden: false ​

typescript
// 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:

typescript
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:

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.

typescript
// 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:

tsx
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.

tsx
{
  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 ​

tsx
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 ​

tsx
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 ​

FunctionReturns
loadUIMessages(){ messages: UIMessage[], hasMore: boolean }
loadAllUIMessages()UIMessage[]

Conversion Function ​

FunctionDescription
convertToAISDKMessages(messages, options)Convert Helix Message[] to AI SDK UIMessage[]

Options ​

typescript
interface LoadUIMessagesOptions {
  offset?: number; // Starting position (default: 0)
  limit?: number; // Max messages to return. Omitted: the whole conversation from `offset` (RM-01)
  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 ​

Released under the MIT License.