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 FrontendHandler

typescript
const { messages, hasMore } = await handler.getMessages(sessionId, {
  offset: 0,
  limit: 50,
  includeReasoning: true,
  includeToolResults: true,
});

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,
});

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 inputbuildMessagesForLLM 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 rehydrationconvertToUIMessages (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 (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

Released under the MIT License.