Skip to main content
This guide demonstrates how to integrate C1 Visualize API with your existing agentic application using a two-step architecture. This pattern allows you to leverage any LLM provider (OpenAI, Anthropic, etc.) for tool-calling and business logic, while using C1 exclusively for generating beautiful, interactive UI components.

Overview

The two-step Visualize pattern separates concerns between your primary LLM and the UI generation layer:
  1. Step 1 - Business Logic: Your primary LLM handles user requests, executes tool calls, and generates a final text/markdown response
  2. Step 2 - UI Generation: C1 Visualize API converts the text response into interactive, generative UI components
This approach is ideal when:
  • You want to use your existing LLM infrastructure for tool-calling
  • You need to maintain complete conversation history with your primary LLM
  • You want to add beautiful UI generation without refactoring your agent logic
For most applications, we recommend using C1 as the Gateway LLM instead, which provides lower latency and better context awareness.

Architecture

Key Concepts

Message Types

The pattern uses two distinct message storage strategies: AIMessage: Complete conversation history with your primary LLM
  • Includes user prompts, assistant responses, tool calls and their results
  • Essential for maintaining context in subsequent LLM calls
UIMessage: Display-optimized messages for the frontend
  • User prompts and final generative UI responses from C1
  • Used for rendering the chat interface

The Two-Step Flow

  1. Step 1: Call your primary LLM with full conversation context, allowing it to use tools and generate a complete response
  2. Step 2: Send the final LLM response to C1 Visualize API, which transforms it into rich UI components without needing tool access

Setup

1. Install Dependencies

npm install openai @thesysai/genui-sdk @crayonai/react-core @crayonai/react-ui @crayonai/stream

2. Environment Variables

Create a .env file with your API keys:
OPENAI_API_KEY=your_openai_api_key
THESYS_API_KEY=your_thesys_api_key
You can create a new API key from Developer Console

3. Configure OpenAI Clients

Create two OpenAI client instances - one for your standard LLM calls and one for C1 Visualize:
import OpenAI from "openai";

// Standard OpenAI client for business logic and tool-calling
const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// C1 Visualize client for UI generation
const thesysClient = new OpenAI({
  baseURL: "https://api.thesys.dev/v1/visualize",
  apiKey: process.env.THESYS_API_KEY,
});

Implementation

Backend API Route

Create an API route that orchestrates the two-step process:
app/api/chat/route.ts
import { NextRequest } from "next/server";
import OpenAI from "openai";
import { transformStream } from "@crayonai/stream";
import { tools } from "./tools";

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

const thesysClient = new OpenAI({
  baseURL: "https://api.thesys.dev/v1/visualize",
  apiKey: process.env.THESYS_API_KEY,
});

const SYSTEM_PROMPT = `You are a helpful assistant.

Your outputs will be processed by a downstream visualizer agent to generate a user interface.
The visualizer agent is responsible for generating a user interface based on your output but it does not have access to the tool calls or the database.
In your response, you should provide enough information with possible actions like buttons, forms, links, etc. that the user can take to interact with the UI.

If user asks for output in a specific component (e.g., graph, table, chart), generate the output in the requested format so the visualizer can render it properly. Give hints in your output so that the downstream agent can use those hints to generate UI properly.`;

export async function POST(req: NextRequest) {
  const { prompt, threadId, responseId } = await req.json();

  // Step 1: Call Standard OpenAI API
  const previousAiMessages = await getAIThreadMessages(threadId);

  // Use runTools helper to handle tool execution automatically
  const runner = openai.beta.chat.completions.runTools({
    model: "gpt-4",
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      ...previousAiMessages,
      { role: "user", content: prompt.content },
    ],
    temperature: 0.1,
    stream: false,
    tools: tools,
  });

  // Wait for OpenAI to complete (including tool calls)
  await runner.done();
  const finalMessages = runner.messages;

  // Step 2: Store AI Interaction History
  const newAiMessagesToStore = [];

  // Add the user prompt
  newAiMessagesToStore.push(prompt);

  // Track messages and extract final assistant message
  let finalAssistantMessageForUI;
  runner.on("message", (message) => {
    newAiMessagesToStore.push({ ...message, id: crypto.randomUUID() });
    if (typeof message.content === "string") {
      finalAssistantMessageForUI = message;
    }
  });

  // Step 3: Validate Final Assistant Content

  if (!finalAssistantMessageForUI ||
      typeof finalAssistantMessageForUI.content !== "string") {
    console.error("No final assistant message found");
    return new Response("", { status: 200 });
  }

  // Step 4: Call C1 Visualize API
  const thesysStreamRunner = thesysClient.beta.chat.completions.runTools({
    model: "c1/anthropic/claude-sonnet-4/v-20251230",
    messages: [
      ...previousAiMessages,
      { role: "user", content: prompt.content },
      { role: "assistant", content: finalAssistantMessageForUI.content },
    ],
    stream: true,
    tools: [], // No tools needed for Visualize API
  });

  // Visualize API returns only one assistant message
  thesysStreamRunner.on("message", async (message) => {
    // Step 5: Store messages after streaming completes
    await addMessages(
      threadId,
      newAiMessagesToStore,
      [prompt, { ...message, id: responseId }]
    );
  });

  const llmStream = await thesysStreamRunner;

  // Step 6: Stream Response to Client
  const responseStream = transformStream(llmStream, (chunk) => {
    return chunk.choices[0]?.delta?.content || "";
  });

  return new Response(responseStream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache, no-transform",
      Connection: "keep-alive",
    },
  });
}

Frontend Component

Use the GenUI SDK to render the chat interface:
app/page.tsx
"use client";

import "@crayonai/react-ui/styles/index.css";
import {
  C1Chat,
  useThreadListManager,
  useThreadManager,
} from "@thesysai/genui-sdk";
import * as apiClient from "@/src/apiClient";

export default function Home() {
  const threadListManager = useThreadListManager({
    fetchThreadList: () => apiClient.getThreadList(),
    deleteThread: (threadId) => apiClient.deleteThread(threadId),
    updateThread: (t) => apiClient.updateThread(t),
    createThread: (message) => apiClient.createThread(message.message),
  });

  const threadManager = useThreadManager({
    threadListManager,
    loadThread: (threadId) => apiClient.getMessages(threadId),
    onUpdateMessage: ({ message }) => {
      apiClient.updateMessage(
        threadListManager.selectedThreadId,
        message
      );
    },
    apiUrl: "/api/chat",
  });

  return (
    <C1Chat
      threadManager={threadManager}
      threadListManager={threadListManager}
    />
  );
}

System Prompt Best Practices

When crafting your system prompt for the primary LLM, include guidance for the downstream visualizer:
  1. Mention the Visualizer: Inform the LLM that its output will be processed by a UI generator
  2. Provide Context: Explain that the visualizer doesn’t have access to tools or database
  3. Request Structure: Ask for actionable elements like buttons, forms, and links
  4. Format Hints: Suggest specific UI components when appropriate (tables, charts, etc.)

Example System Prompt

const SYSTEM_PROMPT = `You are a [domain-specific] assistant.

**Your Workflow:**
1. [Describe your agent's workflow]
2. [List key actions and tools]
3. [Specify behavior patterns]

**Visualizer Agent:**
Your outputs will be processed by a downstream visualizer agent to generate a user interface.
The visualizer agent is responsible for generating a user interface based on your output but it does not have access to the tool calls or the database.
In your response, you should provide enough information with possible actions like buttons to select items, forms to submit data, links to navigate, etc. that the user can take to interact with the UI.

If user asks for output in a specific component (e.g., graph, table, chart), generate the output in the requested format so the visualizer can render it properly. Give hints in your output so that the downstream agent can use those hints to generate UI properly.`;

Message Persistence

Store two types of messages with distinct purposes: AIMessage Table
  • Stores complete LLM conversation history including tool calls and results
  • Used for context in subsequent LLM calls
UIMessage Table
  • Stores user-facing messages with generative UI responses
  • Used for rendering chat interface

Example Prisma Schema

schema.prisma
model Thread {
  id          String      @id @default(uuid())
  title       String?
  createdAt   DateTime    @default(now())
  updatedAt   DateTime    @updatedAt
  aiMessages  AIMessage[]
  uiMessages  UIMessage[]
}

model AIMessage {
  id        String   @id @default(uuid())
  threadId  String
  thread    Thread   @relation(fields: [threadId], references: [id], onDelete: Cascade)
  role      String
  content   String?
  toolCalls Json?
  toolCallId String?
  name      String?
  createdAt DateTime @default(now())
}

model UIMessage {
  id        String   @id @default(uuid())
  threadId  String
  thread    Thread   @relation(fields: [threadId], references: [id], onDelete: Cascade)
  role      String
  content   String?
  createdAt DateTime @default(now())
}

Troubleshooting

Enhance your system prompt to provide more context about expected UI patterns and include relevant metadata in the assistant’s final message.
const SYSTEM_PROMPT = `You are a ${domainName} assistant.
...
Include specific details about ${relevantContext} in your responses.`;
Verify your tools array is correctly formatted and tool functions return valid JSON strings:
// Ensure tool functions return JSON strings
async function myTool(params) {
  const result = await fetchData(params);
  return JSON.stringify(result); // Must return string
}
Implement message summarization or truncation for older messages while keeping recent context:
// Keep only the last N messages
const recentMessages = conversationHistory.slice(-10);
Check that your hosting platform supports streaming responses. For example, on Vercel use Edge Functions instead of Serverless Functions:
app/api/chat/route.ts
export const runtime = 'edge'; // Enable edge runtime for streaming

Best Practices

  1. Separate Concerns: Keep business logic in your primary LLM and UI generation in C1
  2. Rich Context: Provide detailed information in the final assistant message for better UI generation
  3. Actionable Elements: Always include next-step actions (buttons, forms) in your responses
  4. Message Storage: Store both AI and UI message histories for optimal context management
  5. Error Recovery: Implement graceful fallbacks if either API call fails
  6. Testing: Test the full two-step flow with various query types and edge cases
This pattern introduces additional latency since you must wait for the primary LLM to complete before C1 can start streaming. For most applications, consider using C1 as the Gateway LLM instead.

Full Example on GitHub

See a complete working implementation of this pattern in our e-commerce agent example with tools, database integration, and production-ready code.