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.

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)
  generateId?: (index: number, message: Message) => string;
}

Next Steps

Released under the MIT License.