Skip to content

@helix-agents/ai-sdk

Vercel AI SDK UI binding layer for Helix Agents. Transforms Helix internal streaming protocol to AI SDK UI Data Stream protocol for use with useChat and other AI SDK React hooks.

Installation

bash
npm install @helix-agents/ai-sdk

FrontendHandler

Main handler for frontend requests.

createFrontendHandler

Factory function to create a handler.

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

const handler = createFrontendHandler({
  streamManager, // StreamManager instance
  executor, // AgentExecutor instance
  agent, // Agent configuration
  stateStore, // Optional: for getMessages()
  transformerOptions, // Optional
  logger, // Optional
});

handleRequest

Handle incoming HTTP requests.

typescript
// POST - Execute new agent
const response = await handler.handleRequest({
  method: 'POST',
  body: {
    message: 'Hello, agent!',
    state: { userId: 'user-123' }, // Optional initial state
  },
});

// GET - Stream existing execution
const response = await handler.handleRequest({
  method: 'GET',
  streamId: 'run-123',
  resumeAt: 100, // Optional: resume from sequence
});

Returns:

typescript
interface FrontendResponse {
  status: number;
  headers: Record<string, string>;
  body: ReadableStream<Uint8Array> | string;
}

getMessages

Load conversation history for useChat initialMessages.

typescript
const { messages, hasMore } = await handler.getMessages(runId, {
  offset: 0,
  limit: 50,
  includeReasoning: true,
  includeToolResults: true,
  generateId: (index, msg) => `msg-${index}`,
});

Returns: GetUIMessagesResult

typescript
interface GetUIMessagesResult {
  messages: UIMessage[];
  hasMore: boolean;
}

StreamTransformer

Transforms Helix stream chunks to AI SDK UI events.

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

const transformer = new StreamTransformer({
  generateMessageId: (agentId) => `msg-${agentId}`,
  includeStepEvents: false,
  chunkFilter: (chunk) => chunk.type !== 'state_patch',
  logger: console,
});

// Transform chunks
for await (const chunk of helixStream) {
  const { events, sequence } = transformer.transform(chunk);
  for (const event of events) {
    yield event;
  }
}

// Finalize (closes open blocks)
const { events } = transformer.finalize();

Event Mapping

Helix ChunkAI SDK Events
text_deltatext-start, text-delta
thinkingreasoning-start, reasoning-delta, reasoning-end
tool_starttext-end (if needed), tool-input-available
tool_endtool-output-available
subagent_startdata-subagent-start
subagent_enddata-subagent-end
customdata-{eventName}
state_patchdata-state-patch
errorerror
outputdata-output

Message Converter

Convert Helix messages to AI SDK v5 UIMessage format.

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

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

UIMessage Format (AI SDK v5)

typescript
interface UIMessage {
  id: string;
  role: 'user' | 'assistant' | 'system';
  parts: UIMessagePart[];
}

type UIMessagePart = UIMessageTextPart | UIMessageReasoningPart | UIMessageToolInvocationPart;

interface UIMessageTextPart {
  type: 'text';
  text: string;
}

interface UIMessageReasoningPart {
  type: 'reasoning';
  text: string;
}

interface UIMessageToolInvocationPart {
  type: `tool-${string}`; // e.g., 'tool-search'
  toolCallId: string;
  input: Record<string, unknown>;
  state: ToolInvocationState;
  output?: unknown;
  errorText?: string;
}

type ToolInvocationState =
  | 'input-streaming'
  | 'input-available'
  | 'output-available'
  | 'output-error';

SSE Response Builder

Build Server-Sent Events responses.

typescript
import { createSSEStream, createSSEHeaders, buildSSEResponse } from '@helix-agents/ai-sdk';

// Full response
const response = buildSSEResponse(eventsGenerator, {
  headers: { 'X-Custom': 'value' },
});

// Manual construction
const headers = createSSEHeaders({ 'X-Custom': 'value' });
const stream = createSSEStream(eventsGenerator);

SSE Format

id: 1
data: {"type":"text-delta","id":"block-1","delta":"Hello"}

id: 2
data: {"type":"text-delta","id":"block-1","delta":" world"}

data: {"type":"finish"}

Header Utilities

typescript
import {
  AI_SDK_UI_HEADER, // 'X-AI-SDK-UI'
  AI_SDK_UI_HEADER_VALUE, // 'vercel-ai-sdk-ui'
  extractResumePosition,
} from '@helix-agents/ai-sdk';

// Extract resume position from Last-Event-ID
const lastEventId = request.headers.get('Last-Event-ID');
const resumeAt = extractResumePosition(lastEventId);

// Check if request is from AI SDK UI
const isAISDK = request.headers.get(AI_SDK_UI_HEADER) === AI_SDK_UI_HEADER_VALUE;

Errors

All errors extend FrontendHandlerError:

typescript
import {
  FrontendHandlerError,
  ValidationError, // 400: Missing/invalid params
  StreamNotFoundError, // 404: Stream doesn't exist
  StreamReaderError, // 500: Reader creation failed
  StreamFailedError, // 410: Stream has failed
  ConfigurationError, // 501: Missing configuration
  ExecutionError, // 500: Agent execution failed
  StreamCreationError, // 500: Stream creation failed
} from '@helix-agents/ai-sdk';

try {
  await handler.handleRequest(req);
} catch (error) {
  if (error instanceof FrontendHandlerError) {
    return Response.json({ error: error.message, code: error.code }, { status: error.statusCode });
  }
  throw error;
}

Error Properties

typescript
interface FrontendHandlerError extends Error {
  code: string; // e.g., 'VALIDATION_ERROR'
  statusCode: number; // HTTP status code
}

Types

Event Types

typescript
import type {
  AISDKUIEvent,
  AISDKStartEvent,
  AISDKFinishEvent,
  AISDKTextStartEvent,
  AISDKTextDeltaEvent,
  AISDKTextEndEvent,
  AISDKReasoningStartEvent,
  AISDKReasoningDeltaEvent,
  AISDKReasoningEndEvent,
  AISDKToolInputAvailableEvent,
  AISDKToolOutputAvailableEvent,
  AISDKStartStepEvent,
  AISDKFinishStepEvent,
  AISDKDataEvent,
  AISDKErrorEvent,
} from '@helix-agents/ai-sdk';

Configuration Types

typescript
import type {
  StreamTransformerOptions,
  FrontendHandlerOptions,
  FrontendRequest,
  FrontendResponse,
  TransformResult,
  SequencedEvent,
  MessageConvertOptions,
} from '@helix-agents/ai-sdk';

Complete Example

Backend (Hono)

typescript
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { createFrontendHandler, FrontendHandlerError } from '@helix-agents/ai-sdk';
import { JSAgentExecutor } from '@helix-agents/runtime-js';
import { InMemoryStateStore, InMemoryStreamManager } from '@helix-agents/store-memory';
import { VercelAIAdapter } from '@helix-agents/llm-vercel';
import { MyAgent } from './agent.js';

const stateStore = new InMemoryStateStore();
const streamManager = new InMemoryStreamManager();
const executor = new JSAgentExecutor(stateStore, streamManager, new VercelAIAdapter());

const handler = createFrontendHandler({
  streamManager,
  executor,
  agent: MyAgent,
  stateStore,
});

const app = new Hono();

app.use(
  '/api/*',
  cors({
    origin: ['http://localhost:3000'],
    allowHeaders: ['Content-Type', 'Last-Event-ID'],
    exposeHeaders: ['X-Stream-Id'],
  })
);

app.post('/api/chat', async (c) => {
  try {
    const body = await c.req.json();
    const response = await handler.handleRequest({
      method: 'POST',
      body: { message: body.message, state: body.state },
    });
    return new Response(response.body, {
      status: response.status,
      headers: response.headers,
    });
  } catch (error) {
    if (error instanceof FrontendHandlerError) {
      return c.json({ error: error.message, code: error.code }, error.statusCode);
    }
    throw error;
  }
});

app.get('/api/messages/:runId', async (c) => {
  const runId = c.req.param('runId');
  const { messages, hasMore } = await handler.getMessages(runId);
  return c.json({ messages, hasMore });
});

export default app;

Frontend (React)

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

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

  return (
    <div>
      {messages.map((msg) => (
        <div key={msg.id}>
          {msg.parts.map((part, i) => (
            <MessagePart key={i} part={part} />
          ))}
        </div>
      ))}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} disabled={isLoading} />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}

See Also

Released under the MIT License.