Skip to content

React Integration ​

v7 uses AI SDK v6 patterns

This page references the legacy AI SDK v4 useChat API (isLoading, handleInputChange, initialMessages). v7 targets AI SDK v6 (messages, sendMessage, transport, addToolOutput). For the canonical v7 React wiring — DefaultChatTransport + prepareHelixChatRequest + prepareHelixReconnectRequest + useHelixChat (for client-executed tools) — see Frontend / @helix-agents/ai-sdk and the @helix-agents/ai-sdk README — composability ladder.

Build real-time chat UIs with React using Vercel AI SDK's useChat hook. This guide shows how to display streaming text, tool invocations, and thinking content from Helix Agents.

Prerequisites ​

bash
npm install ai @ai-sdk/react

This is the canonical v7 client: it renders the server's snapshot, resumes a live run from the snapshot's cursor, re-anchors after a rewind or a retried attempt, and shows explicit error states. The full contract is in Resumable Streams.

tsx
'use client';

import { useMemo, useState } from 'react';
import { DefaultChatTransport } from 'ai';
import {
  fetchSnapshotWithRetry,
  parseHelixChatError,
  prepareHelixChatRequest,
  prepareHelixReconnectRequest,
  snapshotResumeOptions,
  SnapshotUnavailableError,
} from '@helix-agents/ai-sdk/client';
import { useHelixChat } from '@helix-agents/ai-sdk/react';
import type { FrontendSnapshot } from '@helix-agents/ai-sdk';

function ChatView({
  sessionId,
  snapshot,
}: {
  sessionId: string;
  snapshot: FrontendSnapshot<MyState>;
}) {
  // active / paused: seed WITHOUT the in-flight message and resume with the
  // replayed prefix; ended / failed: seed the whole snapshot, no resume.
  const resumeOptions = useMemo(() => snapshotResumeOptions(snapshot), [snapshot]);

  const transport = useMemo(() => {
    const api = `/api/chat/${sessionId}`;
    return new DefaultChatTransport({
      api,
      prepareSendMessagesRequest: prepareHelixChatRequest({ api }),
      prepareReconnectToStreamRequest: prepareHelixReconnectRequest({
        api,
        resumeFromSequence: resumeOptions.resumeFromSequence,
        existingMessageId: resumeOptions.existingMessageId,
        replayPrefix: resumeOptions.replayPrefix,
      }),
    });
  }, [sessionId, resumeOptions]);

  const chat = useHelixChat({
    id: sessionId,
    transport,
    messages: resumeOptions.messages,
    resume: resumeOptions.resume,
    resync: { snapshotUrl: `/api/chat/${sessionId}/snapshot`, snapshot },
  });

  // A send refused because a run is already active: 409 state_already_running.
  const sendError = parseHelixChatError(chat.error);

  return (
    <div>
      {chat.messages.map((m) => (
        <Message key={m.id} message={m} />
      ))}
      {chat.resyncError && <Banner>Could not reload the conversation.</Banner>}
      {chat.error && (
        <Banner>
          {sendError?.error ?? chat.error.message}
          {sendError?.retryable && <button onClick={() => void chat.regenerate()}>Retry</button>}
        </Banner>
      )}
      <Composer onSend={(text) => chat.sendMessage({ text })} onStop={() => void chat.stop()} />
    </div>
  );
}

useHelixChat returns UseHelixChatResult: useChat's helpers plus resyncError: Error | null (the last re-anchor or re-attach error, such as a ReanchorExhaustedError once it stops re-attaching after a dropped request; cleared by the next success, including a retry() that re-anchors or re-sends). With a transport, its stop aborts the transport connection (on ai < 6.0.281 chat.stop() alone cannot stop a resumed stream) and resolves once the chat is idle, and its sendMessage stops an open resume first. Pass the transport, not a pre-built Chat: only then can the hook keep replayed tool calls from running again.

Loading the snapshot with an error state ​

Read the snapshot on the server for the first render. When the read fails, render an explicit error state with a retry control, never an empty conversation. The retry can read it from the browser:

tsx
function SnapshotGate({
  sessionId,
  initial,
}: {
  sessionId: string;
  initial: FrontendSnapshot<MyState> | null;
}) {
  const [snapshot, setSnapshot] = useState(initial);
  const [error, setError] = useState<string | null>(initial ? null : 'Snapshot unavailable');

  if (snapshot) return <ChatView sessionId={sessionId} snapshot={snapshot} />;

  const retry = async () => {
    try {
      // 4 fetches, 250 / 500 / 1000 ms backoff on a 5xx / 429 / network failure.
      // null means the session does not exist yet.
      const fresh = await fetchSnapshotWithRetry<MyState>(`/api/chat/${sessionId}/snapshot`);
      setSnapshot(fresh ?? emptySnapshot(sessionId));
    } catch (err) {
      setError(
        err instanceof SnapshotUnavailableError ? `${err.code}: ${err.message}` : String(err)
      );
    }
  };

  return (
    <div role="alert">
      This conversation could not be loaded ({error}). <button onClick={retry}>Retry</button>
    </div>
  );
}

examples/resumable-streams-nextjs has a complete SnapshotGate and ChatErrorBanner.

Resync and stream control ​

  • resync: { snapshotUrl, snapshot?, snapshotFetch?, onResync?, onError? } re-anchors on data-stream-resync, data-attempt-superseded and data-tool-output-elsewhere. onResync receives an AISDKResyncTriggerEvent.
  • useAutoResync(collectResyncTriggers(chat.messages), { snapshotUrl, setMessages: chat.setMessages, chat }) is the same thing as a separate hook. Without stream control (chat, both stop and resume, or the chatId of a mounted useHelixChat) it surfaces a typed ConfigurationError instead of re-anchoring.
  • With a plain useChat, wrap the transport with withHelixStreamControl(transport) and stop a chat with transport.abortActive(chat.id) before chat.stop(); build the resync control with chatResyncControl(chat, transport). Such a chat must seed the whole snapshot: snapshotResumeOptions(snapshot, { replayPrefix: false }), and its predicate takes that seed as history (useHelixSendAutomaticallyWhen(id, { history: seed })) so the seed's answered client tools are never sent again (CP-107).

The exports and where they live are listed in Client helpers.

Basic Chat Component ​

tsx
import { useChat } from 'ai/react';

function ChatComponent() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error } = useChat({
    api: '/api/chat',
  });

  return (
    <div className="chat-container">
      {/* Message List */}
      <div className="messages">
        {messages.map((message) => (
          <div key={message.id} className={`message ${message.role}`}>
            <MessageContent message={message} />
          </div>
        ))}
      </div>

      {/* Error Display */}
      {error && <div className="error">Error: {error.message}</div>}

      {/* Input Form */}
      <form onSubmit={handleSubmit} className="input-form">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type a message..."
          disabled={isLoading}
        />
        <button type="submit" disabled={isLoading}>
          {isLoading ? 'Sending...' : 'Send'}
        </button>
      </form>
    </div>
  );
}

Rendering Message Parts (AI SDK v6) ​

In AI SDK v6, messages use parts as the source of truth:

tsx
function MessageContent({ message }: { message: UIMessage }) {
  return (
    <div className="message-content">
      {message.parts.map((part, index) => (
        <MessagePart key={index} part={part} />
      ))}
    </div>
  );
}

function MessagePart({ part }: { part: UIMessagePart }) {
  // Text content
  if (part.type === 'text') {
    return <p className="text-part">{part.text}</p>;
  }

  // Reasoning/thinking content
  if (part.type === 'reasoning') {
    return (
      <div className="reasoning-part">
        <span className="label">Thinking:</span>
        <p>{part.text}</p>
      </div>
    );
  }

  // Tool invocation (type is `tool-${toolName}`)
  if (part.type.startsWith('tool-')) {
    return <ToolInvocation part={part} />;
  }

  return null;
}

Using Type Guards ​

For type-safe filtering and checks, use the provided type guards:

tsx
import {
  isAISDKToolPart,
  hasAISDKPendingTools,
  hasAISDKErroredTools,
  getAISDKToolParts,
} from '@helix-agents/ai-sdk';

function MessagePart({ part }: { part: UIMessagePart }) {
  // Use type guards for safe discrimination
  if (isAISDKToolPart(part)) {
    return <ToolInvocation part={part} />;
  }
  // ...
}

function Message({ message }: { message: UIMessage }) {
  // Check for loading state
  const isLoading = hasAISDKPendingTools(message);

  // Check for errors
  const hasErrors = hasAISDKErroredTools(message);

  // Get all tool parts
  const tools = getAISDKToolParts(message);

  return (
    <div className={`message ${hasErrors ? 'error' : ''}`}>
      {message.parts.map((part, i) => (
        <MessagePart key={i} part={part} />
      ))}
      {isLoading && <LoadingSpinner />}
    </div>
  );
}

Displaying Tool Invocations ​

tsx
function ToolInvocation({ part }: { part: UIMessageToolInvocationPart }) {
  // Extract tool name from type (e.g., 'tool-search' → 'search')
  const toolName = part.type.replace('tool-', '');

  return (
    <div className={`tool-invocation ${part.state}`}>
      <div className="tool-header">
        <ToolIcon name={toolName} />
        <span className="tool-name">{toolName}</span>
        <ToolStatus state={part.state} />
      </div>

      {/* Show input */}
      <div className="tool-input">
        <span className="label">Input:</span>
        <pre>{JSON.stringify(part.input, null, 2)}</pre>
      </div>

      {/* Show output when available */}
      {part.state === 'output-available' && part.output && (
        <div className="tool-output">
          <span className="label">Output:</span>
          <pre>{JSON.stringify(part.output, null, 2)}</pre>
        </div>
      )}

      {/* Show error if failed */}
      {part.state === 'output-error' && part.errorText && (
        <div className="tool-error">
          <span className="label">Error:</span>
          <span>{part.errorText}</span>
        </div>
      )}
    </div>
  );
}

function ToolStatus({ state }: { state: ToolInvocationState }) {
  switch (state) {
    case 'input-streaming':
      return <span className="status streaming">Streaming...</span>;
    case 'input-available':
      return <span className="status pending">Running...</span>;
    case 'output-available':
      return <span className="status success">Completed</span>;
    case 'output-error':
      return <span className="status error">Failed</span>;
  }
}

Streaming Indicators ​

Show streaming state during generation:

tsx
function ChatComponent() {
  const { messages, isLoading, status } = useChat({
    api: '/api/chat',
  });

  return (
    <div>
      {messages.map((message) => (
        <Message key={message.id} message={message} />
      ))}

      {/* Streaming indicator */}
      {isLoading && (
        <div className="streaming-indicator">
          <LoadingDots />
          <span>Agent is thinking...</span>
        </div>
      )}
    </div>
  );
}

Loading Existing Conversations ​

Restore conversation state with initialMessages:

tsx
function ChatPage({ sessionId }: { sessionId: string }) {
  const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);
  const [loading, setLoading] = useState(true);

  // Load messages on mount
  useEffect(() => {
    async function loadMessages() {
      const res = await fetch(`/api/messages/${sessionId}`);
      const { messages } = await res.json();
      setInitialMessages(messages);
      setLoading(false);
    }
    loadMessages();
  }, [sessionId]);

  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: '/api/chat',
    initialMessages,
  });

  if (loading) {
    return <LoadingSpinner />;
  }

  return (
    <div>
      {messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
      {/* ... input form ... */}
    </div>
  );
}

Using Store Utilities ​

For direct access to stored messages, use the store utilities:

tsx
import { loadAllUIMessages } from '@helix-agents/ai-sdk';

function ChatPage({ sessionId }: { sessionId: string }) {
  const [initialMessages, setInitialMessages] = useState<UIMessage[]>([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Load directly from state store
    loadAllUIMessages(stateStore, sessionId).then((messages) => {
      setInitialMessages(messages);
      setLoading(false);
    });
  }, [sessionId]);

  const { messages } = useChat({
    api: '/api/chat',
    initialMessages,
  });

  if (loading) return <LoadingSpinner />;

  return (
    <div>
      {messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}
    </div>
  );
}

Multi-Turn Conversations ​

Enable conversation continuation where each message builds on the previous exchange:

tsx
function MultiTurnChat() {
  // Use a stable sessionId for conversation continuity
  const [sessionId] = useState<string>(() => crypto.randomUUID());

  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat({
    api: '/api/chat',
    // Pass sessionId for all messages in the conversation
    body: { sessionId },
  });

  // Start a new conversation with a new sessionId
  const startNewConversation = () => {
    window.location.reload(); // Or manage sessionId state to generate a new one
  };

  return (
    <div>
      <button onClick={startNewConversation}>New Conversation</button>

      {messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type a message..."
          disabled={isLoading}
        />
        <button type="submit" disabled={isLoading}>
          Send
        </button>
      </form>
    </div>
  );
}

How It Works ​

  1. Generate a sessionId on component mount (or from your URL/state)
  2. Pass sessionId in every request body
  3. Backend automatically continues the conversation within the same session
  4. Each turn creates a new run within the session (for debugging, billing, tracing)

Request Body ​

typescript
// All messages include sessionId
{ message: 'Hello', sessionId: 'session-123' }

// Subsequent messages - same sessionId
{ message: 'Tell me more', sessionId: 'session-123' }

// Or with your own message history
{ message: 'Tell me more', messages: myStoredMessages }

Using External Message History ​

When you manage your own conversation storage:

typescript
const { messages, handleSubmit } = useChat({
  api: '/api/chat',
  body: {
    // Pass your stored messages from your own database
    messages: myStoredMessages.map((m) => ({
      role: m.role,
      content: m.content,
    })),
  },
});

Behavior Table ​

InputMessages SourceState Source
message only (new session)Empty (fresh)Empty (fresh)
message + sessionId (existing)From sessionFrom session
message + messagesFrom messagesEmpty (fresh)
message + stateEmpty (fresh)From state
message + sessionId + messagesFrom messages (override)From session
message + sessionId + stateFrom sessionFrom state (override)
All fourFrom messages (override)From state (override)

Custom Data Events ​

Handle custom events from your agent:

tsx
function ChatComponent() {
  const [customData, setCustomData] = useState<Record<string, unknown>>({});

  const { messages } = useChat({
    api: '/api/chat',
    onResponse: async (response) => {
      // Handle custom data events
      const reader = response.body?.getReader();
      // Process SSE stream for data-* events
    },
  });

  // Or use experimental data streaming
  const { data } = useChat({
    api: '/api/chat',
  });

  useEffect(() => {
    if (data) {
      // Process data events
      for (const item of data) {
        if (item.type === 'data-output') {
          setCustomData(item.data);
        }
      }
    }
  }, [data]);

  return (
    <div>
      {messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}

      {/* Display final output */}
      {customData.output && <OutputDisplay output={customData.output} />}
    </div>
  );
}

Error Handling ​

Handle errors gracefully:

tsx
function ChatComponent() {
  const { messages, error, reload, stop } = useChat({
    api: '/api/chat',
    onError: (error) => {
      console.error('Chat error:', error);
      // Show toast notification, etc.
    },
  });

  return (
    <div>
      {messages.map((msg) => (
        <Message key={msg.id} message={msg} />
      ))}

      {error && (
        <div className="error-banner">
          <p>Something went wrong: {error.message}</p>
          <div className="error-actions">
            <button onClick={() => reload()}>Retry</button>
            <button onClick={() => stop()}>Stop</button>
          </div>
        </div>
      )}
    </div>
  );
}

Typed Stream Errors ​

When errors arrive via the stream, use HelixStreamError for typed error handling:

tsx
import { HelixStreamError } from '@helix-agents/ai-sdk';

function ChatComponent() {
  const { messages, error } = useChat({
    api: '/api/chat',
    onError: (error) => {
      // Check if this is a typed stream error
      if (error instanceof HelixStreamError) {
        if (error.retryable) {
          // Show retry UI
          console.log(`Retryable error (${error.code}): ${error.message}`);
        } else {
          // Show permanent error
          console.log(`Fatal error (${error.code}): ${error.message}`);
        }
      }
    },
  });

  // Render error with retry option
  if (error instanceof HelixStreamError && error.retryable) {
    return (
      <div className="error-banner">
        <p>Temporary error: {error.message}</p>
        <button onClick={reload}>Retry</button>
      </div>
    );
  }
}

Thinking/Reasoning Display ​

Show Claude's thinking or OpenAI's reasoning:

tsx
function ReasoningDisplay({ text }: { text: string }) {
  const [isExpanded, setIsExpanded] = useState(false);

  return (
    <div className="reasoning-block">
      <button className="reasoning-toggle" onClick={() => setIsExpanded(!isExpanded)}>
        <ThinkingIcon />
        <span>View reasoning</span>
        <ChevronIcon direction={isExpanded ? 'up' : 'down'} />
      </button>

      {isExpanded && (
        <div className="reasoning-content">
          <pre>{text}</pre>
        </div>
      )}
    </div>
  );
}

function MessagePart({ part }: { part: UIMessagePart }) {
  if (part.type === 'reasoning') {
    return <ReasoningDisplay text={part.text} />;
  }
  // ... other parts
}

Sub-Agent Display ​

Handle sub-agent events:

tsx
function SubAgentDisplay({
  type,
  sessionId,
  status,
}: {
  type: string;
  sessionId: string;
  status: 'running' | 'completed' | 'failed';
}) {
  return (
    <div className={`subagent ${status}`}>
      <div className="subagent-header">
        <AgentIcon type={type} />
        <span className="subagent-type">{type}</span>
        <span className="subagent-status">
          {status === 'running' ? (
            <Spinner />
          ) : status === 'completed' ? (
            <CheckIcon />
          ) : (
            <ErrorIcon />
          )}
        </span>
      </div>
      <span className="subagent-id">Session: {sessionId}</span>
    </div>
  );
}

Complete Example ​

tsx
import { useChat } from 'ai/react';
import type { UIMessage, UIMessagePart } from '@helix-agents/ai-sdk';

function Chat() {
  const { messages, input, handleInputChange, handleSubmit, isLoading, error, reload, stop } =
    useChat({
      api: '/api/chat',
    });

  return (
    <div className="chat">
      {/* Messages */}
      <div className="messages">
        {messages.map((message) => (
          <div key={message.id} className={`message ${message.role}`}>
            <Avatar role={message.role} />
            <div className="message-body">
              {message.parts.map((part, i) => (
                <MessagePart key={i} part={part} />
              ))}
            </div>
          </div>
        ))}

        {/* Streaming indicator */}
        {isLoading && <StreamingIndicator />}
      </div>

      {/* Error */}
      {error && <ErrorBanner error={error} onRetry={reload} onStop={stop} />}

      {/* Input */}
      <form onSubmit={handleSubmit} className="input-form">
        <textarea
          value={input}
          onChange={handleInputChange}
          placeholder="Message the agent..."
          disabled={isLoading}
          rows={1}
        />
        <button type="submit" disabled={isLoading || !input.trim()}>
          <SendIcon />
        </button>
      </form>
    </div>
  );
}

function MessagePart({ part }: { part: UIMessagePart }) {
  if (part.type === 'text') {
    return <TextPart text={part.text} />;
  }

  if (part.type === 'reasoning') {
    return <ReasoningPart text={part.text} />;
  }

  if (part.type.startsWith('tool-')) {
    return <ToolPart part={part} />;
  }

  return null;
}

function TextPart({ text }: { text: string }) {
  // Use markdown rendering if needed
  return <div className="text-part">{text}</div>;
}

function ReasoningPart({ text }: { text: string }) {
  const [expanded, setExpanded] = useState(false);

  return (
    <div className="reasoning-part">
      <button onClick={() => setExpanded(!expanded)}>{expanded ? 'Hide' : 'Show'} thinking</button>
      {expanded && <pre>{text}</pre>}
    </div>
  );
}

function ToolPart({ part }: { part: UIMessageToolInvocationPart }) {
  const toolName = part.type.replace('tool-', '');

  return (
    <div className={`tool-part ${part.state}`}>
      <div className="tool-header">
        <strong>{toolName}</strong>
        <StatusBadge state={part.state} />
      </div>

      <details>
        <summary>Input</summary>
        <pre>{JSON.stringify(part.input, null, 2)}</pre>
      </details>

      {part.state === 'output-available' && (
        <details open>
          <summary>Output</summary>
          <pre>{JSON.stringify(part.output, null, 2)}</pre>
        </details>
      )}

      {part.state === 'output-error' && <div className="error">{part.errorText}</div>}
    </div>
  );
}

export default Chat;

Styling Tips ​

css
/* Basic chat styling */
.chat {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.messages {
  flex: 1;
  overflow-y: auto;
  padding: 1rem;
}

.message {
  display: flex;
  gap: 0.75rem;
  margin-bottom: 1rem;
}

.message.user {
  flex-direction: row-reverse;
}

.message.assistant .message-body {
  background: #f3f4f6;
  border-radius: 1rem;
  padding: 0.75rem 1rem;
}

/* Tool invocation styling */
.tool-part {
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  padding: 0.75rem;
  margin: 0.5rem 0;
}

.tool-part.input-available {
  border-color: #fbbf24;
  background: #fffbeb;
}

.tool-part.output-available {
  border-color: #22c55e;
  background: #f0fdf4;
}

.tool-part.output-error {
  border-color: #ef4444;
  background: #fef2f2;
}

/* Input form */
.input-form {
  display: flex;
  gap: 0.5rem;
  padding: 1rem;
  border-top: 1px solid #e5e7eb;
}

.input-form textarea {
  flex: 1;
  resize: none;
  border: 1px solid #e5e7eb;
  border-radius: 0.5rem;
  padding: 0.75rem;
}

Next Steps ​

Released under the MIT License.