Skip to main content
Build your own AI-powered search engine that generates beautiful, interactive UI instead of just returning links. This guide teaches you the architecture and techniques behind apps like Perplexity and Google AI Search.
Try it above or visit search-with-c1.vercel.app

What You’ll Learn

  • How to connect C1 with search APIs using tool calling
  • Building multi-provider search (Exa neural search + Google Gemini)
  • Crafting system prompts for rich visual outputs
  • Streaming search results in real-time
  • Setting up C1Chat for conversational search UI
  • Thread management for follow-up questions

Architecture Overview

Modern AI search apps follow this pattern:
User Query → LLM (with search tools) → Search APIs → LLM processes results → C1 generates UI → Stream to user
The key innovation: instead of returning raw search results, we let C1 generate contextual, visual UI based on what the user searched for.

Setup

Prerequisites

  • Node.js 18+
  • Thesys API key from console.thesys.dev
  • Choose one search provider:
    • Exa API key from exa.ai (recommended for neural search)
    • Google Gemini API key from ai.google.dev
  • (Optional) Google Custom Search API key and CSE ID for image search

Create Next.js Project

npm
npx create-next-app@latest search-with-c1
cd search-with-c1
When prompted, select:
  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • App Router: Yes
  • Customize default import alias: No

Install Dependencies

npm
npm install @thesysai/genui-sdk @crayonai/react-ui openai exa-js
If using Google Gemini instead of Exa:
npm
npm install @google/generative-ai
Optional (for image search):
npm
npm install google-images

Environment Variables

Create a .env.local file:
THESYS_API_KEY=your_thesys_api_key

# Choose one search provider
EXA_API_KEY=your_exa_api_key
# OR
GOOGLE_GEMINI_API_KEY=your_gemini_api_key

# Optional: For image search
GOOGLE_API_KEY=your_google_api_key
GOOGLE_CSE_ID=your_custom_search_engine_id
We recommend Exa for better neural search results. Get your API key from exa.ai.

Step 1: Set Up Search Tools

C1 uses OpenAI’s tool calling to trigger searches. Here’s how to define a search tool:
Next.js
const createWebSearchTool = (searchProvider, c1Response) => [{
  type: "function",
  function: {
    name: "webSearch",
    description: "Search the web to get high-quality results with full content",
    parameters: {
      type: "object",
      properties: {
        query: {
          type: "string",
          description: "The search query to perform",
        },
      },
      required: ["query"],
    },
    function: async (args: { query: string }) => {
      // Update user on search progress
      c1Response.writeThinkItem({
        title: "Searching the web",
        description: "Retrieving relevant information...",
      });

      // Call your search provider (Exa or Gemini)
      const searchResult = await exaSearch(args.query);
      return JSON.stringify(searchResult);
    },
  },
}];
The writeThinkItem calls show users what’s happening while search runs in the background. This creates a better UX than silent loading.

Step 2: Implement Search Providers

You have two options for search: Exa provides AI-powered search with full page content extraction:
exa-search.ts
import Exa from "exa-js";

const exa = new Exa(process.env.EXA_API_KEY);

export const exaSearch = async (query: string) => {
  // Search and get full content in one call
  const response = await exa.searchAndContents(query, {
    numResults: 5,
    text: true,           // Get full page content
    highlights: true,      // Get key snippets
    type: "auto",         // Let Exa choose search mode
  });

  return {
    results: response.results.map(r => ({
      title: r.title,
      url: r.url,
      content: r.text,    // Full page text for LLM
      snippet: r.highlights?.[0] || r.text?.slice(0, 200),
    })),
    searchQuery: query,
  };
};

Option B: Google Gemini with Grounding

Gemini 2.5 has built-in Google Search grounding:
gemini-search.ts
import { GoogleGenAI } from "@google/genai";

const genAI = new GoogleGenAI({
  apiKey: process.env.GEMINI_API_KEY,
});

export const googleGenAISearch = async (query: string) => {
  const response = await genAI.models.generateContentStream({
    model: "gemini-2.5-flash",
    contents: [{ role: "user", parts: [{ text: query }] }],
    config: {
      tools: [{ googleSearch: {} }],  // Enable search grounding
      thinkingConfig: {
        includeThoughts: true,         // Stream thinking process
      },
    },
  });

  let result = "";
  for await (const chunk of response) {
    if (chunk.text) result += chunk.text;
  }

  return result;
};

Step 3: Create the Main API Endpoint

Now connect everything with C1:
app/api/ask/route.ts
import { makeC1Response } from "@thesysai/genui-sdk/server";
import { transformStream } from "@crayonai/stream";
import OpenAI from "openai";

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

export async function POST(req: NextRequest) {
  const { prompt, searchProvider } = await req.json();
  const c1Response = makeC1Response();

  // Create search tool
  const tools = createWebSearchTool(searchProvider, c1Response);

  // Call C1 with automatic tool execution
  const llmStream = await client.beta.chat.completions.runTools({
    model: "c1/anthropic/claude-sonnet-4/v-20250915",
    messages: [
      { role: "system", content: SYSTEM_PROMPT },
      { role: "user", content: prompt },
    ],
    tools,
    stream: true,
  });

  // Stream response to user
  transformStream(llmStream, (chunk) => {
    const content = chunk.choices[0]?.delta?.content || "";
    if (content) c1Response.writeContent(content);
    return content;
  }, {
    onEnd: () => c1Response.end(),
  });

  return new Response(c1Response.responseStream, {
    headers: { "Content-Type": "text/event-stream" },
  });
}

Step 4: Craft the Perfect System Prompt

The system prompt determines how C1 generates UI. Here’s a proven pattern for search apps:
const SYSTEM_PROMPT = `You are a visual search AI assistant. Your mission is to provide visually-rich answers.

Today is ${new Date().toLocaleDateString()}.

**Core Directives:**

1. **Always Search First:** Use your web search tool for every query to get current information.
2. **Be Visual:** For any visual topic (places, products, people), MUST use images.
   - Use Image component for single images
   - Use ImageGallery for multiple related images
   - Add images to every ListItem when showing visual things
3. **Visualize Data:** Use tables and charts for statistics, comparisons, and numbers.
4. **Structure Content:** Use headings, lists, and sections to organize information clearly.
5. **Add Follow-ups:** Always include 2-4 follow-up questions to continue the conversation.

**Image Rules:**
- Leave src/imagesSrc/imageSrc fields EMPTY (images are added automatically)
- Provide detailed alt text describing what should be shown
- Examples: "Eiffel Tower illuminated at night", "Graph showing Tesla stock price"

Remember: A great search response is a visual response.`;
The key is being specific about when to use visual components and how to structure them. Vague prompts lead to inconsistent results.
C1 generates image components, but needs actual image URLs to display them. Create an image search endpoint that C1 can call:
app/api/search/image/route.ts
import axios from "axios";
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const { query } = await req.json();

  try {
    // Use Google Custom Search API for images
    const response = await axios.get(
      "https://www.googleapis.com/customsearch/v1",
      {
        params: {
          q: query,
          searchType: "image",
          key: process.env.GOOGLE_API_KEY,
          cx: process.env.GOOGLE_CX,  // Custom Search Engine ID
          num: 1,                      // Just need one image
          safe: "active",              // Safe search
          imgSize: "large",            // High quality
        },
      }
    );

    if (!response.data.items || response.data.items.length === 0) {
      return NextResponse.json({ url: null, thumbnailUrl: null });
    }

    return NextResponse.json({
      url: response.data.items[0].link,
      thumbnailUrl: response.data.items[0].image.thumbnailLink,
    });
  } catch (error) {
    // Return placeholder on error
    return NextResponse.json({
      url: "https://via.placeholder.com/360x240",
      thumbnailUrl: "https://via.placeholder.com/360x240",
    });
  }
}

How Image Search Works

The C1 SDK handles images automatically through a searchImage callback:
Frontend (React)
import { C1Component } from "@thesysai/genui-sdk";

// Your image search function (calls your backend)
const imageSearch = async (query: string) => {
  const response = await fetch("/api/search/image", {
    method: "POST",
    body: JSON.stringify({ query }),
  });
  return response.json(); // { url: "...", thumbnailUrl: "..." }
};

// Pass searchImage to C1Component
<C1Component
  c1Response={response}
  searchImage={imageSearch}  // SDK calls this when it needs images
/>
The flow:
  1. Backend C1 generates image components with descriptive alt text (e.g., alt="Eiffel Tower at sunset")
  2. C1 SDK detects images with empty src attributes
  3. SDK automatically calls your searchImage(altText) function
  4. Your function fetches the actual image URL from your /api/search/image endpoint
  5. SDK updates the component with the real image URL
You don’t need to manually handle image fetching - just provide the searchImage callback to C1Component.
The searchImage callback gives you flexibility: use Google Images, Unsplash, Pexels, or your own image CDN. The SDK just needs a function that takes a query string and returns { url, thumbnailUrl }.
Cache responses to avoid re-searching identical queries:
cache.ts
const cache = new Map<string, { response: string; timestamp: number }>();
const CACHE_TTL = 1000 * 60 * 60; // 1 hour

export const findCachedResponse = (query: string) => {
  const cached = cache.get(query);
  if (!cached) return null;

  // Check if cache expired
  if (Date.now() - cached.timestamp > CACHE_TTL) {
    cache.delete(query);
    return null;
  }

  return cached.response;
};

export const cacheResponse = (query: string, response: string) => {
  cache.set(query, { response, timestamp: Date.now() });
};

Step 7: Add Thread Management for Follow-ups (Optional)

To enable follow-up questions that reference previous searches, implement thread management:
thread-cache.ts
interface ThreadMessage {
  role: "user" | "assistant";
  prompt?: string;        // For user messages
  c1Response?: string;    // For assistant messages
  timestamp: string;
}

// In-memory thread storage (use Redis for production)
const threads = new Map<string, ThreadMessage[]>();

export const getThread = async (threadId: string) => {
  return threads.get(threadId) || [];
};

export const addUserMessage = async (threadId: string, prompt: string) => {
  const thread = threads.get(threadId) || [];
  thread.push({
    role: "user",
    prompt,
    timestamp: new Date().toISOString(),
  });
  threads.set(threadId, thread);
};

export const addAssistantMessage = async (
  threadId: string,
  c1Response: string
) => {
  const thread = threads.get(threadId) || [];
  thread.push({
    role: "assistant",
    c1Response,
    timestamp: new Date().toISOString(),
  });
  threads.set(threadId, thread);
};

Using Thread History

Update your main endpoint to include thread history:
With thread context
export async function POST(req: NextRequest) {
  const { prompt, threadId } = await req.json();
  const c1Response = makeC1Response();

  // Get conversation history
  const threadHistory = await getThread(threadId);

  // Save user message
  await addUserMessage(threadId, prompt);

  // Build messages with history
  const messages = [
    { role: "system", content: SYSTEM_PROMPT },
    // Add previous conversation
    ...threadHistory.map(msg => ({
      role: msg.role,
      content: msg.role === "user" ? msg.prompt : msg.c1Response,
    })),
    // Add current prompt
    { role: "user", content: prompt },
  ];

  // Call C1 with context
  const llmStream = await client.beta.chat.completions.runTools({
    model: "c1/anthropic/claude-sonnet-4/v-20250915",
    messages,
    tools,
    stream: true,
  });

  let finalResponse = "";
  transformStream(llmStream, (chunk) => {
    const content = chunk.choices[0]?.delta?.content || "";
    if (content) {
      finalResponse += content;
      c1Response.writeContent(content);
    }
    return content;
  }, {
    onEnd: async () => {
      // Save assistant response
      await addAssistantMessage(threadId, finalResponse);
      c1Response.end();
    },
  });

  return new Response(c1Response.responseStream, {
    headers: { "Content-Type": "text/event-stream" },
  });
}

Why Thread Management Matters

With threads, users can ask follow-up questions:
User: "What are the best restaurants in Tokyo?"
AI: [Shows image gallery + list of restaurants]

User: "Which one has the best sushi?"
AI: [References previous results, shows specific sushi restaurants]
Without threads, the second question would fail because the AI has no context from the first search.
Production tip: Use Redis or a database for thread storage instead of in-memory. In-memory storage is lost when your server restarts. The search-with-c1 repo includes Redis integration examples.

Step 8: Set Up the Frontend UI

Now create the conversational search interface using C1Chat:
app/page.tsx
"use client";

import { C1Chat } from "@crayonai/react-ui";
import "@crayonai/react-ui/styles/index.css";
import { useState } from "react";
import { searchImage } from "./utils/searchImage";

export default function Page() {
  const [threadId] = useState(() => crypto.randomUUID());

  return (
    <div className="min-h-screen bg-gray-50">
      <C1Chat
        apiUrl="/api/ask"
        threadId={threadId}
        searchImage={searchImage}
        placeholder="Search anything..."
        title="AI Search"
      />
    </div>
  );
}
C1Chat provides the complete conversational UI out of the box, including:
  • Message history
  • Streaming responses
  • Thinking states
  • Automatic thread management
  • Image search integration

Image Search Integration

Create the image search handler (if you set up image search in Step 5):
app/utils/searchImage.ts
export async function searchImage(query: string): Promise<string> {
  const response = await fetch("/api/search-image", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ query }),
  });

  const data = await response.json();
  return data.imageUrl;
}
Create the image search API endpoint:
app/api/search-image/route.ts
import { NextRequest, NextResponse } from "next/server";
import GoogleImages from "google-images";

const client = new GoogleImages(
  process.env.GOOGLE_CSE_ID!,
  process.env.GOOGLE_API_KEY!
);

export async function POST(req: NextRequest) {
  const { query } = await req.json();

  try {
    const results = await client.search(query, { size: "huge" });
    return NextResponse.json({ imageUrl: results[0]?.url || "" });
  } catch (error) {
    console.error("Image search failed:", error);
    return NextResponse.json({ imageUrl: "" });
  }
}
If you skipped Step 5 (image search), simply omit the searchImage prop from C1Chat.

Step 9: Run Your Search App

Start the development server:
npm
npm run dev
Open http://localhost:3000 and try these searches:
  1. “Best restaurants in Tokyo” - See visual results with images
  2. “How does quantum computing work?” - Get structured explanations
  3. Follow up with “What are the main applications?” - Test thread continuity
If you’re using Exa, the first search might be slower as it fetches full page content. Subsequent searches will be faster with caching enabled.

Key Concepts

Tool calling lets the LLM decide when to search and what to search for. The LLM might reformulate the query, do multiple searches, or skip searching if it has enough context from conversation history.
  • Exa: Best for deep content analysis. Returns full page text for the LLM to process.
  • Gemini: Faster and cheaper. Built-in search grounding with automatic result synthesis.
Many apps let users choose (see the live demo).
C1 analyzes the content + your system prompt. If content contains images, lists, or data, and your prompt encourages visual components, C1 will generate appropriate UI. The better your prompt, the better the UI.
Yes! Create additional tools for databases, APIs, or documents. C1 can combine web search with your private data.

Going to Production

Before deploying:
  1. Add rate limiting to prevent API abuse
  2. Implement proper error handling for failed searches
  3. Set up monitoring for API costs and performance
  4. Add user authentication if needed
  5. Enable caching to reduce API calls

Full Example & Source Code