Skip to content

Code Browser

Browse example source files directly in the docs site and copy code with one click.

This page is generated from the examples/ directory by scripts/generate-code-browser.py.


How It Works

  • Use each example's file index to jump to a file viewer section.
  • Expand the file block to inspect code.
  • Use the copy button in each code block to paste into your own project.

Example 01 - Hello Gaia

File Index

File Viewer

examples/01-hello-gaia/.env.example
Text Only
# Your Cohesity Gaia API key
GAIA_API_KEY=your_api_key_here

# Gaia API base URL (change to your cluster if not using Helios)
GAIA_BASE_URL=https://helios.cohesity.com/v2/mcm/gaia

# Set to false if your cluster uses a self-signed certificate
GAIA_VERIFY_SSL=true

examples/01-hello-gaia/README.md
Markdown
# Hello Gaia

The simplest possible Gaia application — a command-line tool that lists your datasets and asks a question.

## Setup

```bash
# Create a virtual environment
python -m venv venv
source venv/bin/activate  # macOS/Linux

# Install dependencies
pip install -r requirements.txt

# Configure your environment
cp .env.example .env
# Edit .env with your Gaia API key and cluster URL
```

## Run

```bash
python hello_gaia.py
```

This will:

1. Connect to your Gaia cluster
2. List available datasets
3. Ask a sample question against the first dataset
4. Print the response and source documents

## What You'll Learn

- How to authenticate with the Gaia API
- How to list datasets
- How to send a RAG query
- How to read the response and source documents

examples/01-hello-gaia/hello_gaia.py
Python
#!/usr/bin/env python3
"""Hello Gaia — your first Gaia application.

This script demonstrates the three fundamental Gaia operations:
1. List datasets
2. Ask a RAG question
3. Read the response and source documents
"""

import asyncio
import os
import sys

import httpx
from dotenv import load_dotenv

load_dotenv()

GAIA_API_KEY = os.getenv("GAIA_API_KEY")
GAIA_BASE_URL = os.getenv("GAIA_BASE_URL", "https://helios.cohesity.com/v2/mcm/gaia")
GAIA_VERIFY_SSL = os.getenv("GAIA_VERIFY_SSL", "true").lower() == "true"


def banner(text: str) -> None:
    width = max(len(text) + 4, 50)
    print(f"\n{'=' * width}")
    print(f"  {text}")
    print(f"{'=' * width}\n")


async def main() -> None:
    if not GAIA_API_KEY:
        print("Error: GAIA_API_KEY is not set.")
        print("Copy .env.example to .env and add your API key.")
        sys.exit(1)

    headers = {
        "apiKey": GAIA_API_KEY,
        "Content-Type": "application/json",
        "Accept": "application/json",
    }

    async with httpx.AsyncClient(
        base_url=GAIA_BASE_URL,
        headers=headers,
        timeout=60,
        verify=GAIA_VERIFY_SSL,
    ) as client:

        # ── Step 1: List Datasets ─────────────────────────────────────
        banner("Step 1: Listing Datasets")

        response = await client.get("/datasets")
        response.raise_for_status()
        data = response.json()

        datasets = data.get("datasets", data) if isinstance(data, dict) else data
        if not datasets:
            print("No datasets found. Create a dataset in the Cohesity UI first.")
            sys.exit(0)

        print(f"Found {len(datasets)} dataset(s):\n")
        for i, ds in enumerate(datasets):
            name = ds.get("name", "Unknown")
            status = ds.get("status", "unknown")
            print(f"  {i + 1}. {name} (status: {status})")

        # ── Step 2: Ask a Question ────────────────────────────────────
        first_dataset = datasets[0].get("name")
        banner(f"Step 2: Asking a Question (dataset: {first_dataset})")

        query = "Give me a brief summary of the data in this dataset."
        print(f"Query: {query}\n")

        response = await client.post("/ask", json={
            "datasetNames": [first_dataset],
            "queryString": query,
        })
        response.raise_for_status()
        result = response.json()

        # ── Step 3: Read the Response ─────────────────────────────────
        banner("Step 3: Response")

        answer = result.get("responseString", "No response received.")
        print(f"Answer:\n{answer}\n")

        documents = result.get("documents", [])
        if documents:
            print(f"\nSource Documents ({len(documents)}):")
            for i, doc in enumerate(documents[:5]):
                filename = doc.get("filename", "Unknown")
                score = doc.get("score", 0)
                print(f"  {i + 1}. {filename} (relevance: {score:.2f})")
        else:
            print("No source documents returned.")

        query_uid = result.get("queryUid")
        conversation_id = result.get("conversationId")
        if query_uid:
            print(f"\nQuery UID: {query_uid}")
        if conversation_id:
            print(f"Conversation ID: {conversation_id}")
            print("(Use this ID to continue the conversation with follow-up questions)")

    banner("Done! You've made your first Gaia API calls.")
    print("Next steps:")
    print("  - Try the chat-app example for a full web interface")
    print("  - Read the documentation at docs/")
    print("  - Build your own application!\n")


if __name__ == "__main__":
    asyncio.run(main())

examples/01-hello-gaia/requirements.txt
Text Only
httpx>=0.27
pydantic>=2.0
python-dotenv>=1.0

Example 02 - Chat App

File Index

File Viewer

examples/02-chat-app/README.md
Markdown
# 02 — Chat App

A full-stack RAG chat application built on **Gaia Intelligence Services (GIS)**. This example shows how to build a conversational interface over your Gaia datasets with streaming responses, conversation history, and session-based authentication.

## What it demonstrates

- **Session-based auth** — The frontend sends a Gaia API key once at login; the backend validates it and returns a session token for subsequent requests.
- **Dataset selection** — Users choose which datasets to query from a live list fetched from Gaia.
- **Streaming responses** — The `/ask/stream` endpoint proxies Server-Sent Events (SSE) from Gaia so tokens render incrementally in the UI.
- **Non-streaming fallback** — The `/ask` endpoint returns a complete response in one shot.
- **Conversation tracking** — Gaia conversation IDs are threaded through requests so follow-up questions keep context.

## Architecture

```
┌─────────────┐     HTTP / SSE     ┌──────────────┐     HTTPS     ┌───────────┐
│  React SPA  │ ◄────────────────► │  FastAPI BFF │ ◄────────────► │  Gaia API │
│  (Vite)     │  localhost:5173    │  (uvicorn)   │  localhost:8000│  (Helios) │
└─────────────┘                    └──────────────┘                └───────────┘
```

The React frontend talks only to the FastAPI backend (a Backend-For-Frontend). The BFF holds session state in memory and forwards requests to Gaia with the user's API key.

## Prerequisites

- Python 3.10+
- Node.js 18+
- A valid Gaia / Helios API key with access to at least one dataset

## Setup

### Backend

```bash
# Run from the examples/02-chat-app/ directory (NOT inside backend/)
python -m venv .venv
source .venv/bin/activate   # Windows: .venv\Scripts\activate

# Install dependencies
pip install -r backend/requirements.txt

# Configure environment
cp backend/.env.example backend/.env
# Edit backend/.env — set GAIA_API_KEY, and optionally GAIA_BASE_URL and GAIA_VERIFY_SSL

# Start the server (must be run from examples/02-chat-app/, not from inside backend/)
uvicorn backend.main:app --reload --port 8000
```

The API is available at http://localhost:8000. Swagger docs are at http://localhost:8000/docs.

### Frontend

```bash
cd frontend

# Install dependencies
npm install

# Configure environment
cp .env.example .env

# Start the dev server
npm run dev
```

Open http://localhost:5173, enter your Gaia API key, select a dataset, and start chatting.

## API Endpoints

| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/v1/auth/login` | Validate API key, create session |
| POST | `/api/v1/auth/logout` | Invalidate session |
| GET | `/api/v1/datasets` | List available datasets |
| POST | `/api/v1/ask` | RAG query (non-streaming) |
| POST | `/api/v1/ask/stream` | RAG query (SSE streaming) |
| GET | `/api/v1/conversations` | List conversations |
| GET | `/api/v1/conversations/{id}/history` | Get conversation messages |

## Project Structure

```
02-chat-app/
├── README.md
├── backend/
│   ├── requirements.txt
│   ├── .env.example
│   ├── main.py              # FastAPI app with CORS & lifespan
│   ├── settings.py           # Pydantic settings from .env
│   ├── api/
│   │   ├── dependencies.py   # Session auth dependency
│   │   └── routes.py         # All API endpoints
│   ├── clients/
│   │   └── gaia_client.py    # Async Gaia HTTP client
│   ├── models/
│   │   └── api_models.py     # Pydantic request/response models
│   ├── services/
│   │   └── session_service.py # In-memory session store
│   └── utils/
│       └── errors.py         # Shared error helpers
└── frontend/
    ├── package.json
    ├── vite.config.ts
    ├── tsconfig.json
    ├── index.html
    ├── .env.example
    └── src/
        ├── main.tsx           # React entry point
        ├── App.tsx            # Route definitions
        ├── api/
        │   ├── client.ts      # Base fetch wrapper with session header
        │   └── gaia.ts        # Typed API functions
        ├── state/
        │   ├── sessionStore.ts # Zustand session store
        │   └── chatStore.ts   # Zustand chat state & streaming
        ├── pages/
        │   ├── LoginPage.tsx   # API key login form
        │   └── ChatPage.tsx   # Main chat interface
        ├── components/
        │   ├── ChatMessage.tsx # Single message with markdown
        │   ├── ChatInput.tsx  # Input bar with send button
        │   └── DatasetSelector.tsx # Sidebar dataset picker
        └── styles/
            └── app.css        # Complete app styles
```

examples/02-chat-app/backend/.env.example
Text Only
# =============================================================================
# Gaia Chat App — environment template
# =============================================================================
# Copy to .env and fill in your values.
# =============================================================================

# Gaia / Helios
GAIA_API_KEY=your-api-key-here
GAIA_BASE_URL=https://helios.cohesity.com/v2/mcm/gaia
GAIA_VERIFY_SSL=false
REQUEST_TIMEOUT_SECONDS=60

# App
ALLOW_CORS_ORIGIN=http://localhost:5173
SESSION_TTL_MINUTES=60

examples/02-chat-app/backend/api/__init__.py
Python

examples/02-chat-app/backend/api/dependencies.py
Python
"""FastAPI dependencies for session-based auth."""

from typing import Optional

from fastapi import Header, HTTPException, status

from backend.services.session_service import get_api_key_for_session
from backend.settings import Settings, get_settings


def require_session(
    x_session_id: Optional[str] = Header(default=None, alias="X-SESSION-ID"),
) -> str:
    """Validate the session header and return the stored Gaia API key."""

    if not x_session_id:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Missing X-SESSION-ID header.",
        )
    api_key = get_api_key_for_session(x_session_id)
    if not api_key:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid or expired session.",
        )
    return api_key

examples/02-chat-app/backend/api/routes.py
Python
"""API routes for the Gaia Chat App."""

import logging
from typing import Optional

from fastapi import APIRouter, Depends, Header, HTTPException, status
from fastapi.responses import StreamingResponse

from backend.api.dependencies import require_session
from backend.models.api_models import (
    AskRequest,
    AskResponse,
    LoginRequest,
    LoginResponse,
)
from backend.services.session_service import create_session, delete_session
from backend.settings import get_settings
from gaia_sdk import GaiaClient
from gaia_sdk.exceptions import GaiaAuthError, GaiaError

logger = logging.getLogger(__name__)
router = APIRouter()


def _make_client(api_key: str) -> GaiaClient:
    s = get_settings()
    return GaiaClient(
        api_key=api_key,
        base_url=s.gaia_base_url,
        timeout=int(s.request_timeout_seconds),
        verify_ssl=s.gaia_verify_ssl,
    )


# ── Auth ─────────────────────────────────────────────────────────────

@router.post("/auth/login", response_model=LoginResponse, tags=["Auth"])
async def login(body: LoginRequest):
    """Validate the API key against Gaia and create a session."""
    async with _make_client(body.api_key) as gaia:
        try:
            await gaia.list_datasets()
        except GaiaAuthError as exc:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail=f"API key validation failed: {exc}",
            )

    settings = get_settings()
    session_id = create_session(body.api_key, ttl_minutes=settings.session_ttl_minutes)
    return LoginResponse(session_id=session_id)


@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT, tags=["Auth"])
async def logout(
    x_session_id: Optional[str] = Header(default=None, alias="X-SESSION-ID"),
):
    if x_session_id:
        delete_session(x_session_id)


# ── Datasets ─────────────────────────────────────────────────────────

@router.get("/datasets", tags=["Datasets"])
async def list_datasets(api_key: str = Depends(require_session)):
    async with _make_client(api_key) as gaia:
        datasets = await gaia.list_datasets()
    return {"datasets": [d.model_dump(by_alias=True) for d in datasets]}


# ── Ask ──────────────────────────────────────────────────────────────

@router.post("/ask", response_model=AskResponse, tags=["Ask"])
async def ask(body: AskRequest, api_key: str = Depends(require_session)):
    async with _make_client(api_key) as gaia:
        result = await gaia.ask(
            dataset_names=body.dataset_names,
            query=body.query,
            conversation_id=body.conversation_id,
        )
    return result.model_dump(by_alias=True)


@router.post("/ask/stream", tags=["Ask"])
async def ask_stream(body: AskRequest, api_key: str = Depends(require_session)):
    """Streaming RAG query — proxies SSE from Gaia."""

    async def event_generator():
        try:
            async with _make_client(api_key) as gaia:
                async for chunk in gaia.ask_stream_iter(
                    dataset_names=body.dataset_names,
                    query=body.query,
                    conversation_id=body.conversation_id,
                ):
                    if chunk.event and chunk.event != "message":
                        yield f"event: {chunk.event}\n"
                    yield f"data: {chunk.data}\n\n"
        except GaiaError:
            yield "data: [ERROR]\n\n"

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "X-Accel-Buffering": "no",
        },
    )


# ── Conversations ────────────────────────────────────────────────────

@router.get("/conversations", tags=["Conversations"])
async def list_conversations(api_key: str = Depends(require_session)):
    async with _make_client(api_key) as gaia:
        return await gaia.list_conversations()


@router.get("/conversations/{conversation_id}/history", tags=["Conversations"])
async def get_chat_history(
    conversation_id: str, api_key: str = Depends(require_session)
):
    async with _make_client(api_key) as gaia:
        return await gaia.get_chat_history(conversation_id)

examples/02-chat-app/backend/main.py
Python
"""FastAPI application for the Gaia Chat App example."""

import contextlib

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from backend.api.routes import router
from backend.services.session_service import cleanup_expired
from backend.settings import get_settings
from gaia_sdk.exceptions import GaiaAuthError, GaiaError, GaiaNotFoundError, GaiaRateLimitError


@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI):
    cleanup_expired()
    yield


def create_app() -> FastAPI:
    settings = get_settings()

    app = FastAPI(
        title="Gaia Chat App",
        version="0.1.0",
        lifespan=lifespan,
    )

    app.add_middleware(
        CORSMiddleware,
        allow_origins=[settings.allow_cors_origin],
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    @app.exception_handler(GaiaError)
    async def gaia_error_handler(request: Request, exc: GaiaError) -> JSONResponse:
        if isinstance(exc, GaiaAuthError):
            status_code = exc.status_code or 401
        elif isinstance(exc, GaiaNotFoundError):
            status_code = 404
        elif isinstance(exc, GaiaRateLimitError):
            status_code = 429
        else:
            status_code = 502
        return JSONResponse(status_code=status_code, content={"detail": str(exc)})

    app.include_router(router, prefix="/api/v1")

    return app


app = create_app()

examples/02-chat-app/backend/models/__init__.py
Python

examples/02-chat-app/backend/models/api_models.py
Python
"""Pydantic request/response models for the Chat App API."""

from typing import List, Optional

from pydantic import BaseModel, ConfigDict, Field


class LoginRequest(BaseModel):
    api_key: str = Field(..., alias="apiKey")
    model_config = ConfigDict(populate_by_name=True)


class LoginResponse(BaseModel):
    session_id: str = Field(..., alias="sessionId")
    model_config = ConfigDict(populate_by_name=True)


class AskRequest(BaseModel):
    query: str
    dataset_names: List[str] = Field(..., alias="datasetNames")
    conversation_id: Optional[str] = Field(default=None, alias="conversationId")
    model_config = ConfigDict(populate_by_name=True)


class AskResponse(BaseModel):
    """Thin pass-through of the Gaia /ask response."""

    response_string: str = Field(default="", alias="responseString")
    query_uid: str = Field(default="", alias="queryUid")
    conversation_id: Optional[str] = Field(default=None, alias="conversationId")
    documents: Optional[List[dict]] = None
    model_config = ConfigDict(populate_by_name=True)


class DatasetItem(BaseModel):
    name: str = ""
    dataset_id: str = Field(default="", alias="datasetId")
    description: Optional[str] = None
    model_config = ConfigDict(populate_by_name=True)


class DatasetListResponse(BaseModel):
    datasets: List[DatasetItem] = Field(default_factory=list)

examples/02-chat-app/backend/requirements.txt
Text Only
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2.0
pydantic-settings>=2.3
# gaia-sdk is a local package — install with: pip install -e sdk/python
# (from the project root) or run: make sdk-install
gaia-sdk

examples/02-chat-app/backend/services/__init__.py
Python

examples/02-chat-app/backend/services/session_service.py
Python
"""In-memory session store keyed by session ID → API key."""

import uuid
from datetime import datetime, timedelta, timezone
from typing import Dict, Optional, Tuple

_sessions: Dict[str, Tuple[str, datetime]] = {}


def create_session(api_key: str, ttl_minutes: int = 60) -> str:
    session_id = uuid.uuid4().hex
    expires = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
    _sessions[session_id] = (api_key, expires)
    return session_id


def get_api_key_for_session(session_id: str) -> Optional[str]:
    entry = _sessions.get(session_id)
    if not entry:
        return None
    api_key, expires = entry
    if datetime.now(timezone.utc) > expires:
        _sessions.pop(session_id, None)
        return None
    return api_key


def delete_session(session_id: str) -> None:
    _sessions.pop(session_id, None)


def cleanup_expired() -> int:
    now = datetime.now(timezone.utc)
    expired = [sid for sid, (_, exp) in _sessions.items() if now > exp]
    for sid in expired:
        del _sessions[sid]
    return len(expired)

examples/02-chat-app/backend/settings.py
Python
"""Configuration loaded from environment variables or .env file."""

from functools import lru_cache

from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", case_sensitive=False)

    gaia_base_url: str = Field(
        default="https://helios.cohesity.com/v2/mcm/gaia",
        validation_alias="GAIA_BASE_URL",
    )
    gaia_verify_ssl: bool = Field(default=True, validation_alias="GAIA_VERIFY_SSL")
    request_timeout_seconds: float = Field(
        default=60.0, validation_alias="REQUEST_TIMEOUT_SECONDS"
    )
    allow_cors_origin: str = Field(
        default="http://localhost:5173", validation_alias="ALLOW_CORS_ORIGIN"
    )
    session_ttl_minutes: int = Field(default=60, validation_alias="SESSION_TTL_MINUTES")


@lru_cache()
def get_settings() -> Settings:
    return Settings()

examples/02-chat-app/backend/utils/__init__.py
Python

examples/02-chat-app/backend/utils/errors.py
Python
"""Shared error helpers."""

from fastapi import HTTPException


def raise_http_error(status_code: int, detail: str) -> None:
    raise HTTPException(status_code=status_code, detail=detail)

examples/02-chat-app/frontend/.env.example
Text Only
VITE_API_URL=http://localhost:8000/api/v1

examples/02-chat-app/frontend/index.html
HTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Gaia Chat</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

examples/02-chat-app/frontend/package.json
JSON
{
  "name": "gaia-chat-app",
  "private": true,
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-markdown": "^9.0.1",
    "react-router-dom": "^6.26.2",
    "zustand": "^4.5.5"
  },
  "devDependencies": {
    "@types/react": "^18.3.11",
    "@types/react-dom": "^18.3.1",
    "@vitejs/plugin-react": "^4.3.2",
    "typescript": "^5.6.3",
    "vite": "^5.4.9"
  }
}

examples/02-chat-app/frontend/src/App.tsx
TSX
import { Navigate, Route, Routes } from "react-router-dom";
import { useSessionStore } from "./state/sessionStore";
import LoginPage from "./pages/LoginPage";
import ChatPage from "./pages/ChatPage";

export default function App() {
  const isAuthenticated = useSessionStore((s) => s.isAuthenticated);

  return (
    <Routes>
      <Route
        path="/"
        element={
          <Navigate to={isAuthenticated ? "/chat" : "/login"} replace />
        }
      />
      <Route path="/login" element={<LoginPage />} />
      <Route
        path="/chat"
        element={isAuthenticated ? <ChatPage /> : <Navigate to="/login" replace />}
      />
    </Routes>
  );
}

examples/02-chat-app/frontend/src/api/client.ts
TypeScript
const API_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000/api/v1";

function getSessionId(): string | null {
  return sessionStorage.getItem("sessionId");
}

function buildHeaders(extra: Record<string, string> = {}): Record<string, string> {
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...extra,
  };
  const sid = getSessionId();
  if (sid) {
    headers["X-SESSION-ID"] = sid;
  }
  return headers;
}

export async function apiFetch<T = unknown>(
  path: string,
  init: RequestInit = {},
): Promise<T> {
  const resp = await fetch(`${API_URL}${path}`, {
    ...init,
    headers: buildHeaders(init.headers as Record<string, string>),
  });

  if (!resp.ok) {
    const body = await resp.json().catch(() => ({ detail: resp.statusText }));
    throw new Error(body.detail ?? `HTTP ${resp.status}`);
  }

  if (resp.status === 204) return undefined as T;
  return resp.json();
}

export function apiStreamUrl(path: string): string {
  return `${API_URL}${path}`;
}

export { getSessionId, buildHeaders, API_URL };

examples/02-chat-app/frontend/src/api/gaia.ts
TypeScript
import { apiFetch, API_URL, buildHeaders } from "./client";

export interface LoginResult {
  sessionId: string;
}

export interface AskResult {
  responseString: string;
  queryUid: string;
  conversationId?: string;
  documents?: unknown[];
}

export interface Dataset {
  name: string;
  datasetId?: string;
  description?: string;
}

export interface Conversation {
  conversationId: string;
  title?: string;
  createdAt?: string;
}

export async function login(apiKey: string): Promise<LoginResult> {
  return apiFetch<LoginResult>("/auth/login", {
    method: "POST",
    body: JSON.stringify({ apiKey }),
  });
}

export async function logout(): Promise<void> {
  await apiFetch("/auth/logout", { method: "POST" });
}

export async function listDatasets(): Promise<Dataset[]> {
  const data = await apiFetch<{ datasets?: Dataset[] }>("/datasets");
  return data.datasets ?? [];
}

export async function ask(
  query: string,
  datasetNames: string[],
  conversationId?: string,
): Promise<AskResult> {
  return apiFetch<AskResult>("/ask", {
    method: "POST",
    body: JSON.stringify({ query, datasetNames, conversationId }),
  });
}

export async function askStream(
  query: string,
  datasetNames: string[],
  conversationId?: string,
  onChunk: (text: string) => void = () => {},
  onDone: (full: string) => void = () => {},
): Promise<void> {
  const resp = await fetch(`${API_URL}/ask/stream`, {
    method: "POST",
    headers: buildHeaders(),
    body: JSON.stringify({ query, datasetNames, conversationId }),
  });

  if (!resp.ok) {
    const body = await resp.json().catch(() => ({ detail: resp.statusText }));
    throw new Error(body.detail ?? `Stream request failed: ${resp.status}`);
  }

  const reader = resp.body?.getReader();
  if (!reader) throw new Error("No response body");

  const decoder = new TextDecoder();
  let accumulated = "";
  let fullText = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    accumulated += decoder.decode(value, { stream: true });
    const lines = accumulated.split("\n");
    accumulated = lines.pop() ?? "";

    for (const line of lines) {
      if (line.startsWith("data: ")) {
        const payload = line.slice(6);
        if (payload === "[DONE]" || payload === "[ERROR]") continue;

        try {
          const parsed = JSON.parse(payload);
          const token =
            parsed.responseString ??
            parsed.token ??
            parsed.delta?.content ??
            parsed.choices?.[0]?.delta?.content ??
            "";
          if (token) {
            fullText += token;
            onChunk(token);
          }
        } catch {
          if (payload.trim()) {
            fullText += payload;
            onChunk(payload);
          }
        }
      }
    }
  }

  onDone(fullText);
}

export async function listConversations(): Promise<Conversation[]> {
  const data = await apiFetch<{ conversations?: Conversation[] }>(
    "/conversations",
  );
  return data.conversations ?? [];
}

export async function getHistory(
  conversationId: string,
): Promise<{ messages: unknown[] }> {
  return apiFetch(`/conversations/${conversationId}/history`);
}

examples/02-chat-app/frontend/src/components/ChatInput.tsx
TSX
import { FormEvent, useState } from "react";
import { useChatStore } from "../state/chatStore";

interface Props {
  disabled?: boolean;
}

export default function ChatInput({ disabled = false }: Props) {
  const [text, setText] = useState("");
  const sendMessage = useChatStore((s) => s.sendMessage);
  const isStreaming = useChatStore((s) => s.isStreaming);

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    const trimmed = text.trim();
    if (!trimmed || disabled || isStreaming) return;
    setText("");
    sendMessage(trimmed);
  };

  return (
    <form className="chat-input-bar" onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder={
          disabled
            ? "Select a dataset to start chatting…"
            : "Ask a question about your data…"
        }
        disabled={disabled || isStreaming}
        autoFocus
      />
      <button type="submit" disabled={disabled || isStreaming || !text.trim()}>
        {isStreaming ? "Streaming…" : "Send"}
      </button>
    </form>
  );
}

examples/02-chat-app/frontend/src/components/ChatMessage.tsx
TSX
import ReactMarkdown from "react-markdown";
import type { ChatMessage as ChatMessageType } from "../state/chatStore";

interface Props {
  message: ChatMessageType;
}

export default function ChatMessage({ message }: Props) {
  const isUser = message.role === "user";

  return (
    <div className={`chat-message ${isUser ? "user" : "assistant"}`}>
      <div className="message-avatar">{isUser ? "You" : "AI"}</div>
      <div className="message-body">
        {isUser ? (
          <p>{message.content}</p>
        ) : (
          <ReactMarkdown>{message.content || "…"}</ReactMarkdown>
        )}
      </div>
    </div>
  );
}

examples/02-chat-app/frontend/src/components/DatasetSelector.tsx
TSX
import { useEffect, useState } from "react";
import { listDatasets, Dataset } from "../api/gaia";
import { useChatStore } from "../state/chatStore";

export default function DatasetSelector() {
  const [datasets, setDatasets] = useState<Dataset[]>([]);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const { selectedDatasets, setSelectedDatasets } = useChatStore();

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    listDatasets()
      .then((ds) => {
        if (!cancelled) {
          setDatasets(ds);
          setError(null);
        }
      })
      .catch((err) => {
        if (!cancelled) setError(err.message);
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  const toggle = (name: string) => {
    if (selectedDatasets.includes(name)) {
      setSelectedDatasets(selectedDatasets.filter((n) => n !== name));
    } else {
      setSelectedDatasets([...selectedDatasets, name]);
    }
  };

  return (
    <div className="dataset-selector">
      <h3>Datasets</h3>

      {loading && <p className="ds-loading">Loading datasets</p>}
      {error && <p className="ds-error">{error}</p>}

      {!loading && datasets.length === 0 && !error && (
        <p className="ds-empty">No datasets found.</p>
      )}

      <ul className="ds-list">
        {datasets.map((ds) => (
          <li key={ds.name}>
            <label className="ds-item">
              <input
                type="checkbox"
                checked={selectedDatasets.includes(ds.name)}
                onChange={() => toggle(ds.name)}
              />
              <span className="ds-name">{ds.name}</span>
            </label>
          </li>
        ))}
      </ul>
    </div>
  );
}

examples/02-chat-app/frontend/src/main.tsx
TSX
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/app.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
);

examples/02-chat-app/frontend/src/pages/ChatPage.tsx
TSX
import { useEffect, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { useSessionStore } from "../state/sessionStore";
import { useChatStore } from "../state/chatStore";
import DatasetSelector from "../components/DatasetSelector";
import ChatMessage from "../components/ChatMessage";
import ChatInput from "../components/ChatInput";

export default function ChatPage() {
  const navigate = useNavigate();
  const logout = useSessionStore((s) => s.logout);
  const { messages, isLoading, isStreaming, selectedDatasets, clearChat } =
    useChatStore();

  const bottomRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    bottomRef.current?.scrollIntoView({ behavior: "smooth" });
  }, [messages]);

  const handleLogout = async () => {
    await logout();
    navigate("/login", { replace: true });
  };

  return (
    <div className="chat-layout">
      {/* Sidebar */}
      <aside className="chat-sidebar">
        <div className="sidebar-header">
          <h2>Gaia Chat</h2>
        </div>

        <DatasetSelector />

        <div className="sidebar-actions">
          <button className="btn-secondary" onClick={clearChat}>
            New Chat
          </button>
          <button className="btn-ghost" onClick={handleLogout}>
            Logout
          </button>
        </div>
      </aside>

      {/* Main area */}
      <main className="chat-main">
        <div className="chat-messages">
          {messages.length === 0 && (
            <div className="chat-empty">
              <h3>Start a conversation</h3>
              <p>
                {selectedDatasets.length === 0
                  ? "Select one or more datasets from the sidebar, then ask a question."
                  : "Ask a question about your selected datasets."}
              </p>
            </div>
          )}

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

          {isStreaming && (
            <div className="streaming-indicator">
              <span className="dot" />
              <span className="dot" />
              <span className="dot" />
            </div>
          )}

          <div ref={bottomRef} />
        </div>

        <ChatInput disabled={isLoading || selectedDatasets.length === 0} />
      </main>
    </div>
  );
}

examples/02-chat-app/frontend/src/pages/LoginPage.tsx
TSX
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useSessionStore } from "../state/sessionStore";

export default function LoginPage() {
  const [apiKey, setApiKey] = useState("");
  const { login, loading, error, isAuthenticated } = useSessionStore();
  const navigate = useNavigate();

  if (isAuthenticated) {
    navigate("/chat", { replace: true });
  }

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    await login(apiKey);
    if (useSessionStore.getState().isAuthenticated) {
      navigate("/chat", { replace: true });
    }
  };

  return (
    <div className="login-page">
      <div className="login-card">
        <div className="login-header">
          <h1>Gaia Chat</h1>
          <p className="login-subtitle">
            Enter your Helios API key to start chatting with your data.
          </p>
        </div>

        <form onSubmit={handleSubmit} className="login-form">
          <label htmlFor="apiKey">API Key</label>
          <input
            id="apiKey"
            type="password"
            value={apiKey}
            onChange={(e) => setApiKey(e.target.value)}
            placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
            required
            autoFocus
          />

          {error && <p className="login-error">{error}</p>}

          <button type="submit" disabled={loading || !apiKey.trim()}>
            {loading ? "Authenticating…" : "Connect"}
          </button>
        </form>

        <p className="login-footnote">
          Your API key is sent to the backend for validation against
          Gaia and stored in an in-memory session. It is never persisted
          to disk.
        </p>
      </div>
    </div>
  );
}

examples/02-chat-app/frontend/src/state/chatStore.ts
TypeScript
import { create } from "zustand";
import { ask, askStream } from "../api/gaia";

export interface ChatMessage {
  id: string;
  role: "user" | "assistant";
  content: string;
}

interface ChatState {
  messages: ChatMessage[];
  currentConversationId: string | null;
  isLoading: boolean;
  isStreaming: boolean;
  selectedDatasets: string[];
  setSelectedDatasets: (names: string[]) => void;
  sendMessage: (query: string, streaming?: boolean) => Promise<void>;
  clearChat: () => void;
}

let messageCounter = 0;
function nextId(): string {
  return `msg-${Date.now()}-${++messageCounter}`;
}

export const useChatStore = create<ChatState>((set, get) => ({
  messages: [],
  currentConversationId: null,
  isLoading: false,
  isStreaming: false,
  selectedDatasets: [],

  setSelectedDatasets: (names) => set({ selectedDatasets: names }),

  sendMessage: async (query: string, streaming = true) => {
    const { selectedDatasets, currentConversationId, messages } = get();
    if (!selectedDatasets.length) return;

    const userMsg: ChatMessage = { id: nextId(), role: "user", content: query };
    set({ messages: [...messages, userMsg], isLoading: true });

    const assistantId = nextId();
    const assistantMsg: ChatMessage = {
      id: assistantId,
      role: "assistant",
      content: "",
    };
    set((s) => ({ messages: [...s.messages, assistantMsg] }));

    if (streaming) {
      set({ isStreaming: true });
      try {
        await askStream(
          query,
          selectedDatasets,
          currentConversationId ?? undefined,
          (token) => {
            set((s) => ({
              messages: s.messages.map((m) =>
                m.id === assistantId ? { ...m, content: m.content + token } : m,
              ),
            }));
          },
          (_full) => {
            set({ isStreaming: false, isLoading: false });
          },
        );
      } catch (err: unknown) {
        const msg = err instanceof Error ? err.message : "Stream error";
        set((s) => ({
          messages: s.messages.map((m) =>
            m.id === assistantId
              ? { ...m, content: `**Error:** ${msg}` }
              : m,
          ),
          isStreaming: false,
          isLoading: false,
        }));
      }
    } else {
      try {
        const result = await ask(
          query,
          selectedDatasets,
          currentConversationId ?? undefined,
        );
        set((s) => ({
          messages: s.messages.map((m) =>
            m.id === assistantId
              ? { ...m, content: result.responseString }
              : m,
          ),
          currentConversationId:
            result.conversationId ?? s.currentConversationId,
          isLoading: false,
        }));
      } catch (err: unknown) {
        const msg = err instanceof Error ? err.message : "Request error";
        set((s) => ({
          messages: s.messages.map((m) =>
            m.id === assistantId
              ? { ...m, content: `**Error:** ${msg}` }
              : m,
          ),
          isLoading: false,
        }));
      }
    }
  },

  clearChat: () =>
    set({ messages: [], currentConversationId: null }),
}));

examples/02-chat-app/frontend/src/state/sessionStore.ts
TypeScript
import { create } from "zustand";
import { login as apiLogin, logout as apiLogout } from "../api/gaia";

interface SessionState {
  sessionId: string | null;
  isAuthenticated: boolean;
  error: string | null;
  loading: boolean;
  login: (apiKey: string) => Promise<void>;
  logout: () => Promise<void>;
  hydrate: () => void;
}

export const useSessionStore = create<SessionState>((set) => ({
  sessionId: null,
  isAuthenticated: false,
  error: null,
  loading: false,

  hydrate: () => {
    const sid = sessionStorage.getItem("sessionId");
    if (sid) {
      set({ sessionId: sid, isAuthenticated: true });
    }
  },

  login: async (apiKey: string) => {
    set({ loading: true, error: null });
    try {
      const { sessionId } = await apiLogin(apiKey);
      sessionStorage.setItem("sessionId", sessionId);
      set({ sessionId, isAuthenticated: true, loading: false });
    } catch (err: unknown) {
      const msg = err instanceof Error ? err.message : "Login failed";
      set({ error: msg, loading: false });
    }
  },

  logout: async () => {
    try {
      await apiLogout();
    } catch {
      // best effort
    }
    sessionStorage.removeItem("sessionId");
    set({ sessionId: null, isAuthenticated: false });
  },
}));

// Rehydrate on module load
useSessionStore.getState().hydrate();

examples/02-chat-app/frontend/src/styles/app.css
CSS
/* ── Reset & base ──────────────────────────────────────────────────── */

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

:root {
  --bg: #0f1117;
  --bg-surface: #1a1b26;
  --bg-surface-hover: #24253a;
  --border: #2e2f3e;
  --text: #e1e2e8;
  --text-muted: #8b8d9e;
  --accent: #6366f1;
  --accent-hover: #818cf8;
  --danger: #ef4444;
  --user-bg: #1e293b;
  --assistant-bg: #1a1b26;
  --radius: 8px;
  --radius-lg: 12px;
  --sidebar-width: 280px;
  --font: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}

html,
body,
#root {
  height: 100%;
}

body {
  font-family: var(--font);
  background: var(--bg);
  color: var(--text);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* ── Login page ────────────────────────────────────────────────────── */

.login-page {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 1rem;
}

.login-card {
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: 2.5rem;
  width: 100%;
  max-width: 440px;
}

.login-header h1 {
  font-size: 1.75rem;
  font-weight: 700;
  margin-bottom: 0.25rem;
}

.login-subtitle {
  color: var(--text-muted);
  font-size: 0.9rem;
  margin-bottom: 1.75rem;
}

.login-form label {
  display: block;
  font-size: 0.85rem;
  font-weight: 500;
  margin-bottom: 0.35rem;
  color: var(--text-muted);
}

.login-form input {
  width: 100%;
  padding: 0.65rem 0.8rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 0.95rem;
  margin-bottom: 1rem;
  outline: none;
  transition: border-color 0.15s;
}

.login-form input:focus {
  border-color: var(--accent);
}

.login-form button {
  width: 100%;
  padding: 0.7rem;
  background: var(--accent);
  color: #fff;
  border: none;
  border-radius: var(--radius);
  font-size: 0.95rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s;
}

.login-form button:hover:not(:disabled) {
  background: var(--accent-hover);
}

.login-form button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.login-error {
  color: var(--danger);
  font-size: 0.85rem;
  margin-bottom: 0.75rem;
}

.login-footnote {
  margin-top: 1.25rem;
  font-size: 0.78rem;
  color: var(--text-muted);
  line-height: 1.5;
}

/* ── Chat layout ───────────────────────────────────────────────────── */

.chat-layout {
  display: flex;
  height: 100vh;
}

.chat-sidebar {
  width: var(--sidebar-width);
  min-width: var(--sidebar-width);
  background: var(--bg-surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  padding: 1.25rem;
  gap: 1rem;
  overflow-y: auto;
}

.sidebar-header h2 {
  font-size: 1.25rem;
  font-weight: 700;
}

.sidebar-actions {
  margin-top: auto;
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

.chat-main {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

/* ── Messages ──────────────────────────────────────────────────────── */

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

.chat-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100%;
  text-align: center;
  color: var(--text-muted);
}

.chat-empty h3 {
  font-size: 1.2rem;
  margin-bottom: 0.5rem;
  color: var(--text);
}

.chat-message {
  display: flex;
  gap: 0.75rem;
  margin-bottom: 1.25rem;
  max-width: 780px;
}

.chat-message.user {
  margin-left: auto;
  flex-direction: row-reverse;
}

.message-avatar {
  flex-shrink: 0;
  width: 36px;
  height: 36px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.7rem;
  font-weight: 700;
  text-transform: uppercase;
}

.chat-message.user .message-avatar {
  background: var(--accent);
  color: #fff;
}

.chat-message.assistant .message-avatar {
  background: #2d2e3f;
  color: var(--accent-hover);
}

.message-body {
  padding: 0.75rem 1rem;
  border-radius: var(--radius-lg);
  font-size: 0.92rem;
  line-height: 1.65;
  max-width: 640px;
}

.chat-message.user .message-body {
  background: var(--user-bg);
  border-bottom-right-radius: 2px;
}

.chat-message.assistant .message-body {
  background: var(--assistant-bg);
  border: 1px solid var(--border);
  border-bottom-left-radius: 2px;
}

.message-body p {
  margin-bottom: 0.5em;
}

.message-body p:last-child {
  margin-bottom: 0;
}

.message-body pre {
  background: var(--bg);
  padding: 0.75rem 1rem;
  border-radius: var(--radius);
  overflow-x: auto;
  margin: 0.5em 0;
  font-size: 0.85rem;
}

.message-body code {
  background: var(--bg);
  padding: 0.15rem 0.35rem;
  border-radius: 4px;
  font-size: 0.85em;
}

.message-body pre code {
  background: none;
  padding: 0;
}

.message-body ul,
.message-body ol {
  margin: 0.5em 0 0.5em 1.25em;
}

/* ── Streaming indicator ───────────────────────────────────────────── */

.streaming-indicator {
  display: flex;
  gap: 4px;
  padding: 0.5rem 0;
  margin-left: 48px;
}

.streaming-indicator .dot {
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--accent);
  animation: blink 1.2s infinite;
}

.streaming-indicator .dot:nth-child(2) {
  animation-delay: 0.2s;
}

.streaming-indicator .dot:nth-child(3) {
  animation-delay: 0.4s;
}

@keyframes blink {
  0%,
  80%,
  100% {
    opacity: 0.25;
  }
  40% {
    opacity: 1;
  }
}

/* ── Chat input bar ────────────────────────────────────────────────── */

.chat-input-bar {
  display: flex;
  gap: 0.5rem;
  padding: 1rem 2rem;
  border-top: 1px solid var(--border);
  background: var(--bg-surface);
}

.chat-input-bar input {
  flex: 1;
  padding: 0.7rem 1rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  font-size: 0.95rem;
  outline: none;
  transition: border-color 0.15s;
}

.chat-input-bar input:focus {
  border-color: var(--accent);
}

.chat-input-bar button {
  padding: 0.7rem 1.4rem;
  background: var(--accent);
  color: #fff;
  border: none;
  border-radius: var(--radius);
  font-weight: 600;
  font-size: 0.9rem;
  cursor: pointer;
  transition: background 0.15s;
  white-space: nowrap;
}

.chat-input-bar button:hover:not(:disabled) {
  background: var(--accent-hover);
}

.chat-input-bar button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* ── Dataset selector ──────────────────────────────────────────────── */

.dataset-selector h3 {
  font-size: 0.85rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-muted);
  margin-bottom: 0.5rem;
}

.ds-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.ds-item {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0.5rem;
  border-radius: var(--radius);
  cursor: pointer;
  transition: background 0.1s;
  font-size: 0.88rem;
}

.ds-item:hover {
  background: var(--bg-surface-hover);
}

.ds-item input[type="checkbox"] {
  accent-color: var(--accent);
}

.ds-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ds-loading,
.ds-error,
.ds-empty {
  font-size: 0.85rem;
  color: var(--text-muted);
  padding: 0.25rem 0;
}

.ds-error {
  color: var(--danger);
}

/* ── Buttons ───────────────────────────────────────────────────────── */

.btn-secondary {
  padding: 0.55rem 1rem;
  background: var(--bg-surface-hover);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  font-size: 0.88rem;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.15s;
}

.btn-secondary:hover {
  background: var(--border);
}

.btn-ghost {
  padding: 0.55rem 1rem;
  background: transparent;
  color: var(--text-muted);
  border: none;
  border-radius: var(--radius);
  font-size: 0.88rem;
  cursor: pointer;
  transition: color 0.15s;
}

.btn-ghost:hover {
  color: var(--text);
}

/* ── Responsive ────────────────────────────────────────────────────── */

@media (max-width: 720px) {
  .chat-layout {
    flex-direction: column;
  }

  .chat-sidebar {
    width: 100%;
    min-width: unset;
    border-right: none;
    border-bottom: 1px solid var(--border);
    max-height: 220px;
  }

  .chat-messages {
    padding: 1rem;
  }

  .chat-input-bar {
    padding: 0.75rem 1rem;
  }
}

examples/02-chat-app/frontend/tsconfig.json
JSON
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": false,
    "noUnusedParameters": false,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"]
}

examples/02-chat-app/frontend/vite.config.ts
TypeScript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
  },
});

File Index

File Viewer

examples/03-document-search/README.md
Markdown
# Example 03 — Document Search

A full-stack document search and analysis tool powered by Cohesity Gaia. This
example demonstrates **exhaustive search**, **pagination**, **similar parts**,
**refine**, **feedback**, and **dataset discovery** — the building blocks for
compliance, e-discovery, and data exploration applications.

## What This Example Demonstrates

| Feature | Gaia Endpoint | Description |
|---|---|---|
| **Exhaustive Search** | `PUT /ask/exhaustive` | Scan an entire dataset and return every matching document, paginated and ranked by relevance. |
| **Pagination** | `paginationToken` | Load results page-by-page for large result sets. |
| **Similar Parts** | `POST /search/similar-document-parts` | Retrieve semantically similar document chunks for a query. |
| **Refine** | `POST /ask/refine` | Select specific documents and regenerate a focused, LLM-synthesized answer. |
| **Feedback** | `POST /query-feedback` | Submit thumbs-up/down ratings to improve future retrieval quality. |
| **Discovery** | `GET /dataset/{id}/discovery` | Browse the hierarchical topic structure of a dataset. |

## Architecture

```
┌──────────────┐     ┌──────────────────┐     ┌──────────────┐
│   React SPA  │────▶│  FastAPI Backend  │────▶│  Gaia API    │
│  (port 5173) │     │   (port 8001)     │     │  (Helios)    │
└──────────────┘     └──────────────────┘     └──────────────┘
```

The frontend never contacts Gaia directly. All API calls go through the backend
which manages session tokens and forwards authenticated requests.

## Prerequisites

- Python 3.10+
- Node.js 18+
- A Cohesity Gaia API key with at least one indexed dataset

## Quick Start

### 1. Backend

```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

cp .env.example .env
# Edit .env and add your GAIA_API_KEY

uvicorn main:app --port 8001 --reload
```

### 2. Frontend

```bash
cd frontend
npm install

cp .env.example .env
# Verify VITE_API_URL=http://localhost:8001/api/v1

npm run dev
```

Open http://localhost:5173, enter your API key, and start searching.

## Project Structure

```
03-document-search/
├── backend/
│   ├── main.py              # FastAPI app with CORS, lifespan
│   ├── settings.py          # Pydantic BaseSettings from .env
│   ├── api/
│   │   ├── routes.py        # All endpoint definitions
│   │   └── dependencies.py  # Session auth dependency injection
│   ├── clients/
│   │   └── gaia_client.py   # Gaia HTTP client wrapper
│   ├── models/
│   │   └── api_models.py    # Request/response Pydantic models
│   ├── services/
│   │   ├── session_service.py  # In-memory session store
│   │   └── search_service.py   # Search orchestration
│   └── utils/
│       └── errors.py        # Error handling utilities
├── frontend/
│   ├── src/
│   │   ├── api/
│   │   │   ├── client.ts    # Base fetch wrapper with session header
│   │   │   └── gaia.ts      # Typed API functions
│   │   ├── state/
│   │   │   ├── sessionStore.ts  # Zustand session state
│   │   │   └── searchStore.ts   # Zustand search/refine state
│   │   ├── pages/
│   │   │   ├── LoginPage.tsx
│   │   │   ├── SearchPage.tsx
│   │   │   └── DatasetBrowserPage.tsx
│   │   ├── components/
│   │   │   ├── SearchBar.tsx
│   │   │   ├── DocumentCard.tsx
│   │   │   ├── DocumentDetail.tsx
│   │   │   ├── RefinePanel.tsx
│   │   │   └── FeedbackButtons.tsx
│   │   └── styles/
│   │       └── app.css
│   ├── package.json
│   ├── vite.config.ts
│   ├── tsconfig.json
│   └── index.html
└── README.md
```

## Key Concepts

### Exhaustive Search vs. `/ask`

The standard `/ask` endpoint returns a synthesized LLM answer from the top-*k*
documents. Exhaustive search (`PUT /ask/exhaustive`) instead scans the full
dataset and returns **every** matching document — no LLM synthesis, just ranked
results with pagination. Use it when completeness matters more than a summary.

### The Refine Flow

1. User performs an exhaustive search.
2. User reviews results and selects the most relevant documents.
3. The app sends selected `docIds` to `POST /ask/refine`.
4. Gaia generates a focused answer using only those documents.

This pairs the completeness of exhaustive search with the synthesis of RAG.

### Feedback Loop

After any search or refinement, users can rate the quality with thumbs-up/down.
This feedback is sent to `POST /query-feedback` and helps Gaia improve retrieval
quality over time.

### Dataset Discovery

The discovery endpoint analyzes a dataset's contents and produces a hierarchical
map of categories and topics with suggested questions — useful for browsing and
onboarding users who don't yet know what to search for.

## Port Configuration

This example uses **port 8001** for the backend to avoid conflicts with
the chat-app example (port 8000). The frontend Vite dev server runs on the
default port 5173.

| Service | Port |
|---|---|
| Backend | 8001 |
| Frontend | 5173 |

examples/03-document-search/backend/.env.example
Text Only
# Cohesity Gaia API key
GAIA_API_KEY=your_api_key_here

# Gaia API base URL (change to your cluster if not using Helios)
GAIA_BASE_URL=https://helios.cohesity.com/v2/mcm/gaia

# Set to false if your cluster uses a self-signed certificate
GAIA_VERIFY_SSL=true

# Request timeout in seconds
REQUEST_TIMEOUT_SECONDS=60

# CORS origin for frontend dev server
ALLOW_CORS_ORIGIN=http://localhost:5173

# Session TTL in minutes
SESSION_TTL_MINUTES=60

# Default page size for exhaustive search results
EXHAUSTIVE_PAGE_SIZE=20

examples/03-document-search/backend/api/__init__.py
Python

examples/03-document-search/backend/api/dependencies.py
Python
"""Dependency injection for session-based auth."""

from fastapi import Header, HTTPException

from services.session_service import get_api_key


async def require_session(x_session_id: str = Header(...)) -> str:
    """Validate the session header and return the associated API key."""
    api_key = get_api_key(x_session_id)
    if not api_key:
        raise HTTPException(status_code=401, detail={"message": "Invalid or expired session"})
    return api_key

examples/03-document-search/backend/api/routes.py
Python
"""API route definitions for document search."""

from fastapi import APIRouter, Depends, Header, HTTPException

from api.dependencies import require_session
from gaia_sdk import GaiaClient
from gaia_sdk.exceptions import GaiaAuthError
from models.api_models import (
    FeedbackRequest,
    LoginRequest,
    LoginResponse,
    RefineRequest,
    RefineResponse,
    SearchNextPageRequest,
    SearchRequest,
    SearchResponse,
    SimilarPartsRequest,
    SimilarPartsResponse,
)
from services.search_service import execute_search
from services.session_service import create_session, destroy_session
from settings import get_settings

router = APIRouter()


def _make_client(api_key: str) -> GaiaClient:
    s = get_settings()
    return GaiaClient(
        api_key=api_key,
        base_url=s.gaia_base_url,
        timeout=s.request_timeout_seconds,
        verify_ssl=s.gaia_verify_ssl,
    )


# ── Auth ──────────────────────────────────────────────────────────────────────

@router.post("/auth/login", response_model=LoginResponse, tags=["Auth"])
async def login(request: LoginRequest):
    """Authenticate with a Gaia API key and receive a session token."""
    async with _make_client(request.api_key) as gaia:
        try:
            await gaia.list_datasets()
        except GaiaAuthError as exc:
            raise HTTPException(status_code=401, detail={"message": str(exc)})

    settings = get_settings()
    session_id = create_session(request.api_key, settings.session_ttl_minutes)
    return LoginResponse(sessionId=session_id)


@router.post("/auth/logout", tags=["Auth"])
async def logout(
    x_session_id: str = Header(...),
):
    """Destroy the current session."""
    destroy_session(x_session_id)
    return {"status": "ok"}


# ── Datasets ──────────────────────────────────────────────────────────────────

@router.get("/datasets", tags=["Datasets"])
async def list_datasets(api_key: str = Depends(require_session)):
    """List available datasets."""
    async with _make_client(api_key) as gaia:
        datasets = await gaia.list_datasets()
    return {"datasets": [d.model_dump(by_alias=True) for d in datasets]}


@router.get("/datasets/{name}/details", tags=["Datasets"])
async def get_dataset_details(name: str, api_key: str = Depends(require_session)):
    """Get details for a specific dataset."""
    async with _make_client(api_key) as gaia:
        details = await gaia.get_dataset(name)
    return details.model_dump(by_alias=True)


# ── Exhaustive Search ─────────────────────────────────────────────────────────

@router.post("/search", response_model=SearchResponse, tags=["Search"])
async def search(request: SearchRequest, api_key: str = Depends(require_session)):
    """Run an exhaustive search across a dataset."""
    result = await execute_search(
        api_key=api_key,
        dataset_name=request.dataset_name,
        query=request.query,
        page_size=request.page_size,
    )
    return result.model_dump(by_alias=True)


@router.post("/search/next", response_model=SearchResponse, tags=["Search"])
async def search_next_page(
    request: SearchNextPageRequest, api_key: str = Depends(require_session)
):
    """Fetch the next page of search results using a pagination token."""
    result = await execute_search(
        api_key=api_key,
        dataset_name=request.dataset_name,
        query=request.query,
        page_size=request.page_size,
        pagination_token=request.pagination_token,
    )
    return result.model_dump(by_alias=True)


# ── Similar Parts ─────────────────────────────────────────────────────────────

@router.post(
    "/similar-parts",
    response_model=SimilarPartsResponse,
    tags=["Search"],
)
async def search_similar_parts(
    request: SimilarPartsRequest, api_key: str = Depends(require_session)
):
    """Search for similar document parts (semantic chunk retrieval)."""
    async with _make_client(api_key) as gaia:
        result = await gaia.search_similar_parts(
            dataset_name=request.dataset_name,
            query=request.query,
        )
    return {"parts": result.get("parts", result.get("documents", []))}


# ── Refine ────────────────────────────────────────────────────────────────────

@router.post("/refine", response_model=RefineResponse, tags=["Refine"])
async def refine(request: RefineRequest, api_key: str = Depends(require_session)):
    """Refine a previous answer using selected documents."""
    async with _make_client(api_key) as gaia:
        result = await gaia.refine(
            query_uid=request.query_uid,
            dataset_names=request.dataset_names,
            query=request.query,
            doc_ids=request.doc_ids,
        )
    return result.model_dump(by_alias=True)


# ── Feedback ──────────────────────────────────────────────────────────────────

@router.post("/feedback", tags=["Feedback"])
async def send_feedback(
    request: FeedbackRequest, api_key: str = Depends(require_session)
):
    """Submit feedback on a query response."""
    async with _make_client(api_key) as gaia:
        await gaia.send_feedback(
            query_uid=request.query_uid,
            is_good=request.is_good,
            feedback_text=request.feedback_text,
        )
    return {"status": "ok"}


# ── Discovery ─────────────────────────────────────────────────────────────────

@router.get("/datasets/{dataset_id}/discovery", tags=["Discovery"])
async def get_discovery(
    dataset_id: str, api_key: str = Depends(require_session)
):
    """Get the discovery hierarchy for a dataset."""
    async with _make_client(api_key) as gaia:
        result = await gaia.get_discovery(dataset_id)
    return result

examples/03-document-search/backend/main.py
Python
"""Document Search — FastAPI application entry point."""

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from api.routes import router
from gaia_sdk.exceptions import GaiaError
from settings import get_settings
from utils.errors import gaia_error_handler


@asynccontextmanager
async def lifespan(app: FastAPI):
    yield


settings = get_settings()

app = FastAPI(
    title="Gaia Document Search",
    description="Document search and analysis powered by Cohesity Gaia",
    version="1.0.0",
    lifespan=lifespan,
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=[settings.allow_cors_origin],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

app.add_exception_handler(GaiaError, gaia_error_handler)

app.include_router(router, prefix="/api/v1")

examples/03-document-search/backend/models/__init__.py
Python

examples/03-document-search/backend/models/api_models.py
Python
"""Pydantic models for request/response validation."""

from __future__ import annotations

from typing import Any

from pydantic import BaseModel, Field


# ── Auth ──────────────────────────────────────────────────────────────────────

class LoginRequest(BaseModel):
    api_key: str = Field(alias="apiKey")

    model_config = {"populate_by_name": True}


class LoginResponse(BaseModel):
    session_id: str = Field(alias="sessionId")

    model_config = {"populate_by_name": True}


# ── Datasets ──────────────────────────────────────────────────────────────────

class DatasetInfo(BaseModel):
    name: str
    status: str | None = None
    description: str | None = None
    object_count: int | None = Field(None, alias="objectCount")

    model_config = {"populate_by_name": True}


class DatasetDetailsResponse(BaseModel):
    name: str
    status: str | None = None
    description: str | None = None
    data_sources: list[dict[str, Any]] | None = Field(None, alias="dataSources")
    indexing_stats: dict[str, Any] | None = Field(None, alias="indexingStats")
    object_count: int | None = Field(None, alias="objectCount")

    model_config = {"populate_by_name": True}


# ── Documents ─────────────────────────────────────────────────────────────────

class DocumentResult(BaseModel):
    doc_id: str | None = Field(None, alias="docId")
    filename: str | None = None
    filepath: str | None = None
    snippet: str | None = None
    score: float | None = None
    metadata: dict[str, Any] | None = None

    model_config = {"populate_by_name": True}


# ── Exhaustive Search ─────────────────────────────────────────────────────────

class SearchRequest(BaseModel):
    dataset_name: str = Field(alias="datasetName")
    query: str
    page_size: int | None = Field(None, alias="pageSize")

    model_config = {"populate_by_name": True}


class SearchNextPageRequest(BaseModel):
    dataset_name: str = Field(alias="datasetName")
    query: str
    pagination_token: str = Field(alias="paginationToken")
    page_size: int | None = Field(None, alias="pageSize")

    model_config = {"populate_by_name": True}


class SearchResponse(BaseModel):
    query_uid: str | None = Field(None, alias="queryUid")
    documents: list[DocumentResult] = []
    total_count: int | None = Field(None, alias="totalCount")
    pagination_token: str | None = Field(None, alias="paginationToken")

    model_config = {"populate_by_name": True}


# ── Similar Parts ─────────────────────────────────────────────────────────────

class SimilarPartsRequest(BaseModel):
    dataset_name: str = Field(alias="datasetName")
    query: str

    model_config = {"populate_by_name": True}


class SimilarPartsResponse(BaseModel):
    parts: list[dict[str, Any]] = []

    model_config = {"populate_by_name": True}


# ── Refine ────────────────────────────────────────────────────────────────────

class RefineRequest(BaseModel):
    query_uid: str = Field(alias="queryUid")
    dataset_names: list[str] = Field(alias="datasetNames")
    query: str
    doc_ids: list[str] = Field(alias="docIds")

    model_config = {"populate_by_name": True}


class RefineResponse(BaseModel):
    response_string: str | None = Field(None, alias="responseString")
    query_uid: str | None = Field(None, alias="queryUid")
    documents: list[DocumentResult] = []

    model_config = {"populate_by_name": True}


# ── Feedback ──────────────────────────────────────────────────────────────────

class FeedbackRequest(BaseModel):
    query_uid: str = Field(alias="queryUid")
    is_good: bool = Field(alias="isGood")
    feedback_text: str | None = Field(None, alias="feedbackText")

    model_config = {"populate_by_name": True}


# ── Discovery ─────────────────────────────────────────────────────────────────

class DiscoveryNode(BaseModel):
    uuid: str | None = None
    name: str | None = None
    level: int | None = None
    description: str | None = None
    suggested_questions: list[str] | None = Field(None, alias="suggestedQuestions")
    children: list[DiscoveryNode] | None = None

    model_config = {"populate_by_name": True}


class DiscoveryResponse(BaseModel):
    discovery_results: list[DiscoveryNode] = Field(
        default_factory=list, alias="discoveryResults"
    )

    model_config = {"populate_by_name": True}

examples/03-document-search/backend/requirements.txt
Text Only
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2.0
pydantic-settings>=2.3
# gaia-sdk is a local package — install with: pip install -e sdk/python
# (from the project root) or run: make sdk-install
gaia-sdk

examples/03-document-search/backend/services/__init__.py
Python

examples/03-document-search/backend/services/search_service.py
Python
"""Search orchestration — combines exhaustive search with optional enrichment."""

from __future__ import annotations

from gaia_sdk import GaiaClient
from gaia_sdk.models import ExhaustiveSearchResponse
from settings import get_settings


def _make_client(api_key: str) -> GaiaClient:
    s = get_settings()
    return GaiaClient(
        api_key=api_key,
        base_url=s.gaia_base_url,
        timeout=s.request_timeout_seconds,
        verify_ssl=s.gaia_verify_ssl,
    )


async def execute_search(
    api_key: str,
    dataset_name: str,
    query: str,
    page_size: int | None = None,
    pagination_token: str | None = None,
) -> ExhaustiveSearchResponse:
    """Run an exhaustive search, returning a typed SDK response."""
    settings = get_settings()
    effective_page_size = page_size or settings.exhaustive_page_size

    async with _make_client(api_key) as gaia:
        return await gaia.exhaustive_search(
            dataset_name=dataset_name,
            query=query,
            page_size=effective_page_size,
            pagination_token=pagination_token,
        )


async def search_with_similar_parts(
    api_key: str,
    dataset_name: str,
    query: str,
    page_size: int | None = None,
) -> dict:
    """Run exhaustive search and enrich results with similar document parts."""
    settings = get_settings()
    effective_page_size = page_size or settings.exhaustive_page_size

    async with _make_client(api_key) as gaia:
        search_result = await gaia.exhaustive_search(
            dataset_name=dataset_name,
            query=query,
            page_size=effective_page_size,
        )

        similar_parts = await gaia.search_similar_parts(
            dataset_name=dataset_name,
            query=query,
        )

    result = search_result.model_dump(by_alias=True)
    result["similarParts"] = similar_parts.get("parts", [])
    return result

examples/03-document-search/backend/services/session_service.py
Python
"""In-memory session store for API key management."""

import secrets
from datetime import datetime, timedelta, timezone

_session_store: dict[str, dict] = {}


def create_session(api_key: str, ttl_minutes: int = 60) -> str:
    session_id = secrets.token_urlsafe(32)
    now = datetime.now(timezone.utc)
    _session_store[session_id] = {
        "api_key": api_key,
        "created_at": now,
        "expires_at": now + timedelta(minutes=ttl_minutes),
    }
    return session_id


def get_api_key(session_id: str) -> str | None:
    session = _session_store.get(session_id)
    if session and session["expires_at"] > datetime.now(timezone.utc):
        return session["api_key"]
    if session:
        _session_store.pop(session_id, None)
    return None


def destroy_session(session_id: str) -> None:
    _session_store.pop(session_id, None)

examples/03-document-search/backend/settings.py
Python
"""Application settings loaded from environment variables."""

from functools import lru_cache

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    gaia_base_url: str = "https://helios.cohesity.com/v2/mcm/gaia"
    gaia_verify_ssl: bool = True
    request_timeout_seconds: int = 60
    allow_cors_origin: str = "http://localhost:5173"
    session_ttl_minutes: int = 60
    exhaustive_page_size: int = 20

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}


@lru_cache
def get_settings() -> Settings:
    return Settings()

examples/03-document-search/backend/utils/__init__.py
Python

examples/03-document-search/backend/utils/errors.py
Python
"""Error handling utilities."""

from fastapi import HTTPException, Request
from fastapi.responses import JSONResponse

from gaia_sdk.exceptions import GaiaAuthError, GaiaError, GaiaNotFoundError, GaiaRateLimitError


def raise_http_error(status: int, message: str) -> None:
    raise HTTPException(status_code=status, detail={"message": message})


async def gaia_error_handler(request: Request, exc: GaiaError) -> JSONResponse:
    """FastAPI exception handler that maps GaiaError subclasses to HTTP responses."""
    if isinstance(exc, GaiaAuthError):
        status_code = exc.status_code or 401
    elif isinstance(exc, GaiaNotFoundError):
        status_code = 404
    elif isinstance(exc, GaiaRateLimitError):
        status_code = 429
    else:
        status_code = 502
    return JSONResponse(status_code=status_code, content={"detail": str(exc)})

examples/03-document-search/frontend/.env.example
Text Only
VITE_API_URL=http://localhost:8001/api/v1

examples/03-document-search/frontend/index.html
HTML
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Gaia Document Search</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

examples/03-document-search/frontend/package.json
JSON
{
  "name": "gaia-document-search",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3",
    "react-dom": "^18.3",
    "react-router-dom": "^6.23",
    "zustand": "^4.5",
    "react-markdown": "^9.0"
  },
  "devDependencies": {
    "@types/react": "^18.3",
    "@types/react-dom": "^18.3",
    "@vitejs/plugin-react": "^4.3",
    "typescript": "^5.5",
    "vite": "^5.3"
  }
}

examples/03-document-search/frontend/src/App.tsx
TSX
import { Routes, Route, Navigate } from "react-router-dom";
import { useSessionStore } from "./state/sessionStore";
import LoginPage from "./pages/LoginPage";
import SearchPage from "./pages/SearchPage";
import DatasetBrowserPage from "./pages/DatasetBrowserPage";

function ProtectedRoute({ children }: { children: React.ReactNode }) {
  const isAuthenticated = useSessionStore((s) => s.isAuthenticated);
  if (!isAuthenticated) return <Navigate to="/login" replace />;
  return <>{children}</>;
}

export default function App() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route
        path="/search"
        element={
          <ProtectedRoute>
            <SearchPage />
          </ProtectedRoute>
        }
      />
      <Route
        path="/datasets"
        element={
          <ProtectedRoute>
            <DatasetBrowserPage />
          </ProtectedRoute>
        }
      />
      <Route path="*" element={<Navigate to="/search" replace />} />
    </Routes>
  );
}

examples/03-document-search/frontend/src/api/client.ts
TypeScript
const API_BASE = import.meta.env.VITE_API_URL || "http://localhost:8001/api/v1";

function getSessionId(): string | null {
  return sessionStorage.getItem("sessionId");
}

export class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

export async function apiRequest<T>(
  path: string,
  options: RequestInit = {},
): Promise<T> {
  const sessionId = getSessionId();
  const headers: Record<string, string> = {
    "Content-Type": "application/json",
    ...(sessionId ? { "X-SESSION-ID": sessionId } : {}),
    ...((options.headers as Record<string, string>) || {}),
  };

  const response = await fetch(`${API_BASE}${path}`, { ...options, headers });

  if (!response.ok) {
    const error = await response
      .json()
      .catch(() => ({ detail: { message: response.statusText } }));
    const message =
      error?.detail?.message || error?.message || "Request failed";
    throw new ApiError(response.status, message);
  }

  return response.json();
}

examples/03-document-search/frontend/src/api/gaia.ts
TypeScript
import { apiRequest } from "./client";

// ── Types ────────────────────────────────────────────────────────────────────

export interface Dataset {
  name: string;
  status?: string;
  description?: string;
  objectCount?: number;
}

export interface DocumentResult {
  docId?: string;
  filename?: string;
  filepath?: string;
  snippet?: string;
  score?: number;
  metadata?: Record<string, unknown>;
}

export interface SearchResponse {
  queryUid?: string;
  documents: DocumentResult[];
  totalCount?: number;
  paginationToken?: string | null;
}

export interface RefineResponse {
  responseString?: string;
  queryUid?: string;
  documents: DocumentResult[];
}

export interface SimilarPartsResponse {
  parts: Record<string, unknown>[];
}

export interface DiscoveryNode {
  uuid?: string;
  name?: string;
  level?: number;
  description?: string;
  suggestedQuestions?: string[];
  children?: DiscoveryNode[];
}

export interface DiscoveryResponse {
  discoveryResults: DiscoveryNode[];
}

export interface DatasetDetails {
  name: string;
  status?: string;
  description?: string;
  dataSources?: Record<string, unknown>[];
  indexingStats?: Record<string, unknown>;
  objectCount?: number;
}

// ── API Functions ────────────────────────────────────────────────────────────

export const gaiaApi = {
  login: (apiKey: string) =>
    apiRequest<{ sessionId: string }>("/auth/login", {
      method: "POST",
      body: JSON.stringify({ apiKey }),
    }),

  logout: () =>
    apiRequest<{ status: string }>("/auth/logout", {
      method: "POST",
    }),

  listDatasets: () =>
    apiRequest<{ datasets: Dataset[] }>("/datasets"),

  getDatasetDetails: (name: string) =>
    apiRequest<DatasetDetails>(`/datasets/${encodeURIComponent(name)}/details`),

  search: (datasetName: string, query: string, pageSize?: number) =>
    apiRequest<SearchResponse>("/search", {
      method: "POST",
      body: JSON.stringify({ datasetName, query, pageSize }),
    }),

  searchNextPage: (
    datasetName: string,
    query: string,
    paginationToken: string,
    pageSize?: number,
  ) =>
    apiRequest<SearchResponse>("/search/next", {
      method: "POST",
      body: JSON.stringify({ datasetName, query, paginationToken, pageSize }),
    }),

  searchSimilarParts: (datasetName: string, query: string) =>
    apiRequest<SimilarPartsResponse>("/similar-parts", {
      method: "POST",
      body: JSON.stringify({ datasetName, query }),
    }),

  refine: (
    queryUid: string,
    datasetNames: string[],
    query: string,
    docIds: string[],
  ) =>
    apiRequest<RefineResponse>("/refine", {
      method: "POST",
      body: JSON.stringify({ queryUid, datasetNames, queryString: query, docIds }),
    }),

  sendFeedback: (queryUid: string, isGood: boolean, feedbackText?: string) =>
    apiRequest<{ status: string }>("/feedback", {
      method: "POST",
      body: JSON.stringify({ queryUid, isGood, feedbackText }),
    }),

  getDiscovery: (datasetId: string) =>
    apiRequest<DiscoveryResponse>(
      `/datasets/${encodeURIComponent(datasetId)}/discovery`,
    ),
};

examples/03-document-search/frontend/src/components/DocumentCard.tsx
TSX
import type { DocumentResult } from "../api/gaia";

interface Props {
  document: DocumentResult;
  isSelected: boolean;
  isActive: boolean;
  onToggleSelect: () => void;
  onClick: () => void;
}

export default function DocumentCard({
  document: doc,
  isSelected,
  isActive,
  onToggleSelect,
  onClick,
}: Props) {
  return (
    <div
      className={`document-card ${isActive ? "active" : ""} ${isSelected ? "selected" : ""}`}
      onClick={onClick}
    >
      <div className="doc-card-top">
        <label className="doc-checkbox" onClick={(e) => e.stopPropagation()}>
          <input
            type="checkbox"
            checked={isSelected}
            onChange={onToggleSelect}
          />
        </label>
        <div className="doc-info">
          <span className="doc-filename">{doc.filename || "Untitled"}</span>
          {doc.filepath && (
            <span className="doc-filepath">{doc.filepath}</span>
          )}
        </div>
        {doc.score != null && (
          <span className="doc-score">
            {(doc.score * 100).toFixed(0)}%
          </span>
        )}
      </div>
      {doc.snippet && (
        <p className="doc-snippet">{doc.snippet}</p>
      )}
    </div>
  );
}

examples/03-document-search/frontend/src/components/DocumentDetail.tsx
TSX
import type { DocumentResult } from "../api/gaia";
import { useSearchStore } from "../state/searchStore";

interface Props {
  document: DocumentResult;
}

export default function DocumentDetail({ document: doc }: Props) {
  const setActiveDocument = useSearchStore((s) => s.setActiveDocument);

  return (
    <div className="document-detail">
      <div className="detail-top-bar">
        <h3>Document Detail</h3>
        <button className="btn-ghost btn-sm" onClick={() => setActiveDocument(null)}>
          Close
        </button>
      </div>

      <div className="detail-fields">
        <div className="field">
          <span className="field-label">Filename</span>
          <span className="field-value">{doc.filename || "Unknown"}</span>
        </div>

        {doc.filepath && (
          <div className="field">
            <span className="field-label">Path</span>
            <span className="field-value">{doc.filepath}</span>
          </div>
        )}

        {doc.docId && (
          <div className="field">
            <span className="field-label">Document ID</span>
            <span className="field-value mono">{doc.docId}</span>
          </div>
        )}

        {doc.score != null && (
          <div className="field">
            <span className="field-label">Relevance Score</span>
            <div className="score-bar-container">
              <div
                className="score-bar-fill"
                style={{ width: `${doc.score * 100}%` }}
              />
              <span className="score-bar-text">{(doc.score * 100).toFixed(1)}%</span>
            </div>
          </div>
        )}

        {doc.metadata && Object.keys(doc.metadata).length > 0 && (
          <div className="field">
            <span className="field-label">Metadata</span>
            <pre className="metadata-json">
              {JSON.stringify(doc.metadata, null, 2)}
            </pre>
          </div>
        )}
      </div>

      {doc.snippet && (
        <div className="detail-snippet">
          <span className="field-label">Snippet</span>
          <p>{doc.snippet}</p>
        </div>
      )}
    </div>
  );
}

examples/03-document-search/frontend/src/components/FeedbackButtons.tsx
TSX
import { useState } from "react";
import { useSearchStore } from "../state/searchStore";

interface Props {
  queryUid: string;
}

export default function FeedbackButtons({ queryUid }: Props) {
  const { feedbackSubmitted, submitFeedback } = useSearchStore();
  const [showTextInput, setShowTextInput] = useState(false);
  const [feedbackText, setFeedbackText] = useState("");

  const submitted = feedbackSubmitted[queryUid];

  if (submitted) {
    return <span className="feedback-thanks">Thanks for your feedback</span>;
  }

  const handleFeedback = async (isGood: boolean) => {
    if (!isGood) {
      setShowTextInput(true);
      return;
    }
    await submitFeedback(queryUid, true);
  };

  const handleSubmitNegative = async () => {
    await submitFeedback(queryUid, false, feedbackText || undefined);
    setShowTextInput(false);
  };

  return (
    <div className="feedback-buttons">
      {!showTextInput ? (
        <>
          <button
            className="feedback-btn good"
            onClick={() => handleFeedback(true)}
            title="Good result"
          >
            &#x1F44D;
          </button>
          <button
            className="feedback-btn bad"
            onClick={() => handleFeedback(false)}
            title="Bad result"
          >
            &#x1F44E;
          </button>
        </>
      ) : (
        <div className="feedback-text-input">
          <input
            type="text"
            placeholder="What could be better?"
            value={feedbackText}
            onChange={(e) => setFeedbackText(e.target.value)}
            autoFocus
          />
          <button className="btn-ghost btn-sm" onClick={handleSubmitNegative}>
            Send
          </button>
          <button className="btn-ghost btn-sm" onClick={() => setShowTextInput(false)}>
            Cancel
          </button>
        </div>
      )}
    </div>
  );
}

examples/03-document-search/frontend/src/components/RefinePanel.tsx
TSX
import ReactMarkdown from "react-markdown";
import { useSearchStore } from "../state/searchStore";
import FeedbackButtons from "./FeedbackButtons";
import type { RefineResponse } from "../api/gaia";

interface Props {
  refinedAnswer: RefineResponse | null;
}

export default function RefinePanel({ refinedAnswer }: Props) {
  const { selectedDocIds, isRefining, refine, clearDocSelection } =
    useSearchStore();

  return (
    <div className="refine-panel">
      <h3>Refine Answer</h3>

      <p className="refine-hint">
        {selectedDocIds.size > 0
          ? `${selectedDocIds.size} document(s) selected. Click Refine to generate a focused answer.`
          : "Select documents from the results to refine the answer."}
      </p>

      <div className="refine-actions">
        <button
          className="btn-primary"
          onClick={refine}
          disabled={selectedDocIds.size === 0 || isRefining}
        >
          {isRefining ? "Refining..." : "Refine"}
        </button>
        {selectedDocIds.size > 0 && (
          <button className="btn-ghost btn-sm" onClick={clearDocSelection}>
            Clear Selection
          </button>
        )}
      </div>

      {refinedAnswer?.responseString && (
        <div className="refined-answer">
          <div className="refined-answer-header">
            <span className="refined-label">Refined Answer</span>
            {refinedAnswer.queryUid && (
              <FeedbackButtons queryUid={refinedAnswer.queryUid} />
            )}
          </div>
          <div className="markdown-content">
            <ReactMarkdown>{refinedAnswer.responseString}</ReactMarkdown>
          </div>
          {refinedAnswer.documents.length > 0 && (
            <div className="refined-sources">
              <span className="field-label">Sources used:</span>
              <ul>
                {refinedAnswer.documents.map((d, i) => (
                  <li key={d.docId || i}>{d.filename || d.docId}</li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

examples/03-document-search/frontend/src/components/SearchBar.tsx
TSX
import { type FormEvent } from "react";
import { useSearchStore } from "../state/searchStore";

export default function SearchBar() {
  const {
    query,
    selectedDataset,
    datasets,
    isLoading,
    setQuery,
    setSelectedDataset,
    search,
  } = useSearchStore();

  const handleSubmit = (e: FormEvent) => {
    e.preventDefault();
    search();
  };

  return (
    <form className="search-bar" onSubmit={handleSubmit}>
      <select
        className="dataset-select"
        value={selectedDataset}
        onChange={(e) => setSelectedDataset(e.target.value)}
      >
        {datasets.length === 0 && <option value="">Loading datasets...</option>}
        {datasets.map((ds) => (
          <option key={ds.name} value={ds.name}>
            {ds.name}
          </option>
        ))}
      </select>

      <input
        type="text"
        className="search-input"
        placeholder="Search documents..."
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        autoFocus
      />

      <button
        type="submit"
        className="btn-primary search-btn"
        disabled={isLoading || !query.trim() || !selectedDataset}
      >
        {isLoading ? "Searching..." : "Search"}
      </button>
    </form>
  );
}

examples/03-document-search/frontend/src/main.tsx
TSX
import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./styles/app.css";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
);

examples/03-document-search/frontend/src/pages/DatasetBrowserPage.tsx
TSX
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useSessionStore } from "../state/sessionStore";
import { gaiaApi, type DatasetDetails, type DiscoveryNode } from "../api/gaia";

export default function DatasetBrowserPage() {
  const logout = useSessionStore((s) => s.logout);
  const [datasets, setDatasets] = useState<{ name: string; status?: string }[]>([]);
  const [selected, setSelected] = useState<string | null>(null);
  const [details, setDetails] = useState<DatasetDetails | null>(null);
  const [discovery, setDiscovery] = useState<DiscoveryNode[]>([]);
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    gaiaApi.listDatasets().then((d) => setDatasets(d.datasets || []));
  }, []);

  const selectDataset = async (name: string) => {
    setSelected(name);
    setLoading(true);
    try {
      const [det, disc] = await Promise.all([
        gaiaApi.getDatasetDetails(name),
        gaiaApi.getDiscovery(name).catch(() => ({ discoveryResults: [] })),
      ]);
      setDetails(det);
      setDiscovery(disc.discoveryResults || []);
    } catch {
      setDetails(null);
      setDiscovery([]);
    } finally {
      setLoading(false);
    }
  };

  const toggle = (uuid: string) => {
    setExpanded((prev) => {
      const next = new Set(prev);
      if (next.has(uuid)) next.delete(uuid);
      else next.add(uuid);
      return next;
    });
  };

  const renderNode = (node: DiscoveryNode, depth = 0): React.ReactNode => (
    <div key={node.uuid || node.name} style={{ marginLeft: depth * 20 }} className="discovery-node">
      <button className="discovery-toggle" onClick={() => node.uuid && toggle(node.uuid)}>
        <span className="toggle-icon">
          {node.children && node.children.length > 0
            ? expanded.has(node.uuid || "") ? "\u25BC" : "\u25B6"
            : "\u2022"}
        </span>
        <span className="node-name">{node.name}</span>
      </button>
      {node.uuid && expanded.has(node.uuid) && (
        <div className="node-detail">
          {node.description && <p className="node-desc">{node.description}</p>}
          {node.suggestedQuestions && node.suggestedQuestions.length > 0 && (
            <div className="suggested-questions">
              <span className="sq-label">Suggested questions:</span>
              <ul>
                {node.suggestedQuestions.map((q, i) => (
                  <li key={i}>
                    <Link to={`/search?q=${encodeURIComponent(q)}`} className="sq-link">
                      {q}
                    </Link>
                  </li>
                ))}
              </ul>
            </div>
          )}
          {node.children?.map((child) => renderNode(child, depth + 1))}
        </div>
      )}
    </div>
  );

  return (
    <div className="dataset-browser-layout">
      <header className="app-header">
        <div className="header-left">
          <h1>Document Search</h1>
          <nav className="header-nav">
            <Link to="/search" className="nav-link">Search</Link>
            <Link to="/datasets" className="nav-link active">Datasets</Link>
          </nav>
        </div>
        <button className="btn-ghost" onClick={logout}>Logout</button>
      </header>

      <main className="browser-main">
        <div className="dataset-list-panel">
          <h2>Datasets</h2>
          <ul className="dataset-list">
            {datasets.map((ds) => (
              <li key={ds.name}>
                <button
                  className={`dataset-item ${selected === ds.name ? "active" : ""}`}
                  onClick={() => selectDataset(ds.name)}
                >
                  <span className="ds-name">{ds.name}</span>
                  {ds.status && <span className="ds-status">{ds.status}</span>}
                </button>
              </li>
            ))}
            {datasets.length === 0 && (
              <li className="empty-hint">No datasets available</li>
            )}
          </ul>
        </div>

        <div className="dataset-detail-panel">
          {loading && (
            <div className="loading-state">
              <div className="spinner" />
            </div>
          )}

          {!loading && details && (
            <>
              <div className="detail-header">
                <h2>{details.name}</h2>
                {details.status && <span className="badge">{details.status}</span>}
              </div>

              {details.description && <p className="detail-desc">{details.description}</p>}

              <div className="detail-stats">
                {details.objectCount != null && (
                  <div className="stat">
                    <span className="stat-value">{details.objectCount}</span>
                    <span className="stat-label">Objects</span>
                  </div>
                )}
                {details.indexingStats && (
                  <>
                    {details.indexingStats.totalChunks != null && (
                      <div className="stat">
                        <span className="stat-value">
                          {String(details.indexingStats.totalChunks)}
                        </span>
                        <span className="stat-label">Chunks</span>
                      </div>
                    )}
                  </>
                )}
              </div>

              {discovery.length > 0 && (
                <div className="discovery-section">
                  <h3>Content Discovery</h3>
                  <div className="discovery-tree">
                    {discovery.map((node) => renderNode(node))}
                  </div>
                </div>
              )}
            </>
          )}

          {!loading && !details && (
            <div className="empty-state">
              <h2>Select a dataset</h2>
              <p>Choose a dataset from the list to view its details and content hierarchy.</p>
            </div>
          )}
        </div>
      </main>
    </div>
  );
}

examples/03-document-search/frontend/src/pages/LoginPage.tsx
TSX
import { useState, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { useSessionStore } from "../state/sessionStore";

export default function LoginPage() {
  const [apiKey, setApiKey] = useState("");
  const [loading, setLoading] = useState(false);
  const login = useSessionStore((s) => s.login);
  const error = useSessionStore((s) => s.error);
  const navigate = useNavigate();

  const handleSubmit = async (e: FormEvent) => {
    e.preventDefault();
    if (!apiKey.trim()) return;
    setLoading(true);
    try {
      await login(apiKey.trim());
      navigate("/search");
    } catch {
      // error is set in store
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="login-page">
      <div className="login-card">
        <div className="login-header">
          <h1>Gaia Document Search</h1>
          <p>Enter your Cohesity Gaia API key to get started.</p>
        </div>
        <form onSubmit={handleSubmit}>
          <label htmlFor="apiKey">API Key</label>
          <input
            id="apiKey"
            type="password"
            placeholder="Paste your Gaia API key"
            value={apiKey}
            onChange={(e) => setApiKey(e.target.value)}
            autoFocus
          />
          {error && <p className="error-text">{error}</p>}
          <button type="submit" className="btn-primary" disabled={loading || !apiKey.trim()}>
            {loading ? "Connecting..." : "Connect"}
          </button>
        </form>
      </div>
    </div>
  );
}

examples/03-document-search/frontend/src/pages/SearchPage.tsx
TSX
import { useEffect } from "react";
import { Link } from "react-router-dom";
import { useSessionStore } from "../state/sessionStore";
import { useSearchStore } from "../state/searchStore";
import SearchBar from "../components/SearchBar";
import DocumentCard from "../components/DocumentCard";
import DocumentDetail from "../components/DocumentDetail";
import RefinePanel from "../components/RefinePanel";
import FeedbackButtons from "../components/FeedbackButtons";

export default function SearchPage() {
  const logout = useSessionStore((s) => s.logout);
  const {
    datasets,
    datasetsLoading,
    results,
    totalCount,
    queryUid,
    isLoading,
    hasMore,
    selectedDocIds,
    refinedAnswer,
    activeDocument,
    error,
    loadDatasets,
    loadMore,
    setActiveDocument,
  } = useSearchStore();

  useEffect(() => {
    if (datasets.length === 0 && !datasetsLoading) {
      loadDatasets();
    }
  }, [datasets.length, datasetsLoading, loadDatasets]);

  return (
    <div className="search-layout">
      <header className="app-header">
        <div className="header-left">
          <h1>Document Search</h1>
          <nav className="header-nav">
            <Link to="/search" className="nav-link active">
              Search
            </Link>
            <Link to="/datasets" className="nav-link">
              Datasets
            </Link>
          </nav>
        </div>
        <button className="btn-ghost" onClick={logout}>
          Logout
        </button>
      </header>

      <main className="search-main">
        <div className="search-content">
          <SearchBar />

          {error && <div className="error-banner">{error}</div>}

          {results.length > 0 && (
            <div className="results-header">
              <span className="results-count">
                {totalCount != null ? `${totalCount} documents found` : `${results.length} documents`}
              </span>
              {queryUid && <FeedbackButtons queryUid={queryUid} />}
            </div>
          )}

          <div className="results-list">
            {results.map((doc, idx) => (
              <DocumentCard
                key={doc.docId || idx}
                document={doc}
                isSelected={!!doc.docId && selectedDocIds.has(doc.docId)}
                isActive={activeDocument?.docId === doc.docId}
                onToggleSelect={() => doc.docId && useSearchStore.getState().toggleDocSelection(doc.docId)}
                onClick={() => setActiveDocument(doc)}
              />
            ))}
          </div>

          {hasMore && (
            <button
              className="btn-secondary load-more-btn"
              onClick={loadMore}
              disabled={isLoading}
            >
              {isLoading ? "Loading..." : "Load More Results"}
            </button>
          )}

          {isLoading && results.length === 0 && (
            <div className="loading-state">
              <div className="spinner" />
              <p>Searching documents...</p>
            </div>
          )}

          {!isLoading && results.length === 0 && queryUid === null && (
            <div className="empty-state">
              <h2>Search your datasets</h2>
              <p>
                Use exhaustive search to find every matching document in a
                dataset. Select a dataset, enter your query, and press Search.
              </p>
            </div>
          )}
        </div>

        <aside className="search-sidebar">
          {activeDocument ? (
            <DocumentDetail document={activeDocument} />
          ) : (
            (selectedDocIds.size > 0 || refinedAnswer) && (
              <RefinePanel refinedAnswer={refinedAnswer} />
            )
          )}
        </aside>
      </main>
    </div>
  );
}

examples/03-document-search/frontend/src/state/searchStore.ts
TypeScript
import { create } from "zustand";
import {
  gaiaApi,
  type DocumentResult,
  type RefineResponse,
  type Dataset,
} from "../api/gaia";

interface SearchState {
  // Datasets
  datasets: Dataset[];
  selectedDataset: string;
  datasetsLoading: boolean;

  // Search
  query: string;
  results: DocumentResult[];
  totalCount: number;
  queryUid: string | null;
  paginationToken: string | null;
  isLoading: boolean;
  hasMore: boolean;

  // Selection & Refine
  selectedDocIds: Set<string>;
  refinedAnswer: RefineResponse | null;
  isRefining: boolean;

  // Feedback
  feedbackSubmitted: Record<string, boolean>;

  // Detail view
  activeDocument: DocumentResult | null;

  // Error
  error: string | null;

  // Actions
  setQuery: (query: string) => void;
  setSelectedDataset: (name: string) => void;
  loadDatasets: () => Promise<void>;
  search: () => Promise<void>;
  loadMore: () => Promise<void>;
  toggleDocSelection: (docId: string) => void;
  selectAllDocs: () => void;
  clearDocSelection: () => void;
  refine: () => Promise<void>;
  submitFeedback: (queryUid: string, isGood: boolean, text?: string) => Promise<void>;
  setActiveDocument: (doc: DocumentResult | null) => void;
  reset: () => void;
}

export const useSearchStore = create<SearchState>((set, get) => ({
  datasets: [],
  selectedDataset: "",
  datasetsLoading: false,

  query: "",
  results: [],
  totalCount: 0,
  queryUid: null,
  paginationToken: null,
  isLoading: false,
  hasMore: false,

  selectedDocIds: new Set(),
  refinedAnswer: null,
  isRefining: false,

  feedbackSubmitted: {},

  activeDocument: null,

  error: null,

  setQuery: (query) => set({ query }),

  setSelectedDataset: (name) => set({ selectedDataset: name }),

  loadDatasets: async () => {
    set({ datasetsLoading: true });
    try {
      const { datasets } = await gaiaApi.listDatasets();
      set({
        datasets,
        datasetsLoading: false,
        selectedDataset: datasets.length > 0 ? datasets[0].name : "",
      });
    } catch (err) {
      set({
        datasetsLoading: false,
        error: err instanceof Error ? err.message : "Failed to load datasets",
      });
    }
  },

  search: async () => {
    const { query, selectedDataset } = get();
    if (!query.trim() || !selectedDataset) return;

    set({
      isLoading: true,
      error: null,
      results: [],
      totalCount: 0,
      queryUid: null,
      paginationToken: null,
      hasMore: false,
      selectedDocIds: new Set(),
      refinedAnswer: null,
      activeDocument: null,
    });

    try {
      const data = await gaiaApi.search(selectedDataset, query);
      set({
        results: data.documents || [],
        totalCount: data.totalCount ?? 0,
        queryUid: data.queryUid ?? null,
        paginationToken: data.paginationToken ?? null,
        hasMore: !!data.paginationToken,
        isLoading: false,
      });
    } catch (err) {
      set({
        isLoading: false,
        error: err instanceof Error ? err.message : "Search failed",
      });
    }
  },

  loadMore: async () => {
    const { query, selectedDataset, paginationToken, isLoading } = get();
    if (!paginationToken || isLoading) return;

    set({ isLoading: true });
    try {
      const data = await gaiaApi.searchNextPage(
        selectedDataset,
        query,
        paginationToken,
      );
      set((state) => ({
        results: [...state.results, ...(data.documents || [])],
        paginationToken: data.paginationToken ?? null,
        hasMore: !!data.paginationToken,
        isLoading: false,
      }));
    } catch (err) {
      set({
        isLoading: false,
        error: err instanceof Error ? err.message : "Failed to load more results",
      });
    }
  },

  toggleDocSelection: (docId) =>
    set((state) => {
      const next = new Set(state.selectedDocIds);
      if (next.has(docId)) {
        next.delete(docId);
      } else {
        next.add(docId);
      }
      return { selectedDocIds: next };
    }),

  selectAllDocs: () =>
    set((state) => ({
      selectedDocIds: new Set(
        state.results
          .map((d) => d.docId)
          .filter((id): id is string => !!id),
      ),
    })),

  clearDocSelection: () => set({ selectedDocIds: new Set() }),

  refine: async () => {
    const { queryUid, selectedDataset, query, selectedDocIds } = get();
    if (!queryUid || selectedDocIds.size === 0) return;

    set({ isRefining: true, error: null });
    try {
      const result = await gaiaApi.refine(
        queryUid,
        [selectedDataset],
        query,
        Array.from(selectedDocIds),
      );
      set({ refinedAnswer: result, isRefining: false });
    } catch (err) {
      set({
        isRefining: false,
        error: err instanceof Error ? err.message : "Refine failed",
      });
    }
  },

  submitFeedback: async (queryUid, isGood, text) => {
    try {
      await gaiaApi.sendFeedback(queryUid, isGood, text);
      set((state) => ({
        feedbackSubmitted: { ...state.feedbackSubmitted, [queryUid]: true },
      }));
    } catch {
      // best-effort
    }
  },

  setActiveDocument: (doc) => set({ activeDocument: doc }),

  reset: () =>
    set({
      query: "",
      results: [],
      totalCount: 0,
      queryUid: null,
      paginationToken: null,
      isLoading: false,
      hasMore: false,
      selectedDocIds: new Set(),
      refinedAnswer: null,
      isRefining: false,
      feedbackSubmitted: {},
      activeDocument: null,
      error: null,
    }),
}));

examples/03-document-search/frontend/src/state/sessionStore.ts
TypeScript
import { create } from "zustand";
import { gaiaApi } from "../api/gaia";

interface SessionState {
  sessionId: string | null;
  isAuthenticated: boolean;
  error: string | null;
  login: (apiKey: string) => Promise<void>;
  logout: () => void;
}

export const useSessionStore = create<SessionState>((set) => ({
  sessionId: sessionStorage.getItem("sessionId"),
  isAuthenticated: !!sessionStorage.getItem("sessionId"),
  error: null,

  login: async (apiKey: string) => {
    try {
      set({ error: null });
      const { sessionId } = await gaiaApi.login(apiKey);
      sessionStorage.setItem("sessionId", sessionId);
      set({ sessionId, isAuthenticated: true });
    } catch (err) {
      const message = err instanceof Error ? err.message : "Login failed";
      set({ error: message });
      throw err;
    }
  },

  logout: () => {
    gaiaApi.logout().catch(() => {});
    sessionStorage.removeItem("sessionId");
    set({ sessionId: null, isAuthenticated: false, error: null });
  },
}));

examples/03-document-search/frontend/src/styles/app.css
CSS
/* ── Reset & Base ─────────────────────────────────────────────────────────── */

*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

:root {
  --color-bg: #f8f9fb;
  --color-surface: #ffffff;
  --color-border: #e2e5ea;
  --color-border-hover: #c5c9d1;
  --color-text: #1a1d23;
  --color-text-secondary: #5f6774;
  --color-text-muted: #8b919d;
  --color-primary: #4f46e5;
  --color-primary-hover: #4338ca;
  --color-primary-light: #eef2ff;
  --color-accent: #06b6d4;
  --color-success: #10b981;
  --color-danger: #ef4444;
  --color-score-fill: #4f46e5;
  --radius-sm: 6px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
  --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
    sans-serif;
  --font-mono: "JetBrains Mono", "Fira Code", monospace;
}

body {
  font-family: var(--font-sans);
  background: var(--color-bg);
  color: var(--color-text);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* ── Buttons ──────────────────────────────────────────────────────────────── */

.btn-primary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 10px 20px;
  background: var(--color-primary);
  color: #fff;
  border: none;
  border-radius: var(--radius-md);
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
  background: var(--color-primary-hover);
}
.btn-primary:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-secondary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 8px 16px;
  background: var(--color-surface);
  color: var(--color-primary);
  border: 1px solid var(--color-primary);
  border-radius: var(--radius-md);
  font-size: 0.875rem;
  font-weight: 500;
  cursor: pointer;
  transition: background 0.15s;
}
.btn-secondary:hover:not(:disabled) {
  background: var(--color-primary-light);
}
.btn-secondary:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

.btn-ghost {
  padding: 6px 12px;
  background: transparent;
  color: var(--color-text-secondary);
  border: none;
  border-radius: var(--radius-sm);
  font-size: 0.8125rem;
  cursor: pointer;
  transition: background 0.15s, color 0.15s;
}
.btn-ghost:hover {
  background: var(--color-border);
  color: var(--color-text);
}

.btn-sm {
  padding: 4px 10px;
  font-size: 0.75rem;
}

/* ── Login Page ───────────────────────────────────────────────────────────── */

.login-page {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 100vh;
  padding: 24px;
}

.login-card {
  width: 100%;
  max-width: 420px;
  background: var(--color-surface);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  padding: 40px;
}

.login-header {
  margin-bottom: 28px;
}
.login-header h1 {
  font-size: 1.5rem;
  font-weight: 700;
  margin-bottom: 8px;
}
.login-header p {
  color: var(--color-text-secondary);
  font-size: 0.875rem;
}

.login-card form {
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.login-card label {
  font-size: 0.8125rem;
  font-weight: 600;
  color: var(--color-text-secondary);
}
.login-card input {
  padding: 10px 14px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  font-size: 0.875rem;
  outline: none;
  transition: border-color 0.15s;
}
.login-card input:focus {
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}

.error-text {
  color: var(--color-danger);
  font-size: 0.8125rem;
}

/* ── App Header ───────────────────────────────────────────────────────────── */

.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 12px 24px;
  background: var(--color-surface);
  border-bottom: 1px solid var(--color-border);
}

.header-left {
  display: flex;
  align-items: center;
  gap: 24px;
}
.header-left h1 {
  font-size: 1.125rem;
  font-weight: 700;
}

.header-nav {
  display: flex;
  gap: 4px;
}
.nav-link {
  padding: 6px 14px;
  border-radius: var(--radius-sm);
  font-size: 0.8125rem;
  font-weight: 500;
  color: var(--color-text-secondary);
  text-decoration: none;
  transition: background 0.15s, color 0.15s;
}
.nav-link:hover {
  background: var(--color-bg);
  color: var(--color-text);
}
.nav-link.active {
  background: var(--color-primary-light);
  color: var(--color-primary);
}

/* ── Search Layout ────────────────────────────────────────────────────────── */

.search-layout {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.search-main {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.search-content {
  flex: 1;
  padding: 24px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.search-sidebar {
  width: 380px;
  min-width: 380px;
  border-left: 1px solid var(--color-border);
  overflow-y: auto;
  background: var(--color-surface);
}

/* ── Search Bar ───────────────────────────────────────────────────────────── */

.search-bar {
  display: flex;
  gap: 8px;
  align-items: stretch;
}

.dataset-select {
  padding: 10px 14px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  font-size: 0.875rem;
  background: var(--color-surface);
  color: var(--color-text);
  min-width: 180px;
  cursor: pointer;
  outline: none;
}
.dataset-select:focus {
  border-color: var(--color-primary);
}

.search-input {
  flex: 1;
  padding: 10px 14px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  font-size: 0.875rem;
  outline: none;
  transition: border-color 0.15s;
}
.search-input:focus {
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}

.search-btn {
  white-space: nowrap;
}

/* ── Results ──────────────────────────────────────────────────────────────── */

.results-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
}

.results-count {
  font-size: 0.8125rem;
  color: var(--color-text-secondary);
  font-weight: 500;
}

.results-list {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.load-more-btn {
  align-self: center;
  margin-top: 8px;
}

.error-banner {
  padding: 10px 14px;
  background: #fef2f2;
  border: 1px solid #fecaca;
  border-radius: var(--radius-md);
  color: var(--color-danger);
  font-size: 0.8125rem;
}

/* ── Document Card ────────────────────────────────────────────────────────── */

.document-card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 14px 16px;
  cursor: pointer;
  transition: border-color 0.15s, box-shadow 0.15s;
}
.document-card:hover {
  border-color: var(--color-border-hover);
  box-shadow: var(--shadow-sm);
}
.document-card.active {
  border-color: var(--color-primary);
  box-shadow: 0 0 0 2px rgba(79, 70, 229, 0.15);
}
.document-card.selected {
  background: var(--color-primary-light);
}

.doc-card-top {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}

.doc-checkbox {
  margin-top: 2px;
  cursor: pointer;
}
.doc-checkbox input {
  width: 16px;
  height: 16px;
  accent-color: var(--color-primary);
  cursor: pointer;
}

.doc-info {
  flex: 1;
  display: flex;
  flex-direction: column;
  min-width: 0;
}

.doc-filename {
  font-weight: 600;
  font-size: 0.875rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.doc-filepath {
  font-size: 0.75rem;
  color: var(--color-text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.doc-score {
  flex-shrink: 0;
  padding: 2px 8px;
  background: var(--color-primary-light);
  color: var(--color-primary);
  border-radius: 100px;
  font-size: 0.75rem;
  font-weight: 600;
}

.doc-snippet {
  margin-top: 8px;
  font-size: 0.8125rem;
  color: var(--color-text-secondary);
  line-height: 1.5;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ── Document Detail ──────────────────────────────────────────────────────── */

.document-detail {
  padding: 20px;
}

.detail-top-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 20px;
}
.detail-top-bar h3 {
  font-size: 0.9375rem;
  font-weight: 700;
}

.detail-fields {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.field-label {
  font-size: 0.6875rem;
  font-weight: 600;
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.field-value {
  font-size: 0.8125rem;
  word-break: break-all;
}
.field-value.mono {
  font-family: var(--font-mono);
  font-size: 0.75rem;
}

.score-bar-container {
  position: relative;
  height: 22px;
  background: var(--color-bg);
  border-radius: 100px;
  overflow: hidden;
}
.score-bar-fill {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  background: var(--color-score-fill);
  border-radius: 100px;
  transition: width 0.3s ease;
}
.score-bar-text {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-size: 0.6875rem;
  font-weight: 700;
  color: var(--color-text);
}

.metadata-json {
  background: var(--color-bg);
  padding: 10px 12px;
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  line-height: 1.5;
  overflow-x: auto;
  white-space: pre-wrap;
}

.detail-snippet {
  margin-top: 16px;
}
.detail-snippet p {
  font-size: 0.8125rem;
  color: var(--color-text-secondary);
  line-height: 1.6;
  margin-top: 6px;
}

/* ── Refine Panel ─────────────────────────────────────────────────────────── */

.refine-panel {
  padding: 20px;
}
.refine-panel h3 {
  font-size: 0.9375rem;
  font-weight: 700;
  margin-bottom: 8px;
}

.refine-hint {
  font-size: 0.8125rem;
  color: var(--color-text-secondary);
  margin-bottom: 14px;
}

.refine-actions {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-bottom: 16px;
}

.refined-answer {
  margin-top: 16px;
  padding-top: 16px;
  border-top: 1px solid var(--color-border);
}

.refined-answer-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 10px;
}

.refined-label {
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  color: var(--color-success);
  letter-spacing: 0.04em;
}

.markdown-content {
  font-size: 0.875rem;
  line-height: 1.7;
  color: var(--color-text);
}
.markdown-content p + p {
  margin-top: 12px;
}
.markdown-content ul,
.markdown-content ol {
  padding-left: 20px;
  margin-top: 8px;
}
.markdown-content code {
  background: var(--color-bg);
  padding: 2px 6px;
  border-radius: 4px;
  font-family: var(--font-mono);
  font-size: 0.8125rem;
}

.refined-sources {
  margin-top: 12px;
}
.refined-sources ul {
  list-style: disc;
  padding-left: 18px;
  margin-top: 4px;
}
.refined-sources li {
  font-size: 0.75rem;
  color: var(--color-text-secondary);
}

/* ── Feedback Buttons ─────────────────────────────────────────────────────── */

.feedback-buttons {
  display: flex;
  align-items: center;
  gap: 4px;
}

.feedback-btn {
  background: none;
  border: none;
  font-size: 1rem;
  cursor: pointer;
  padding: 2px 6px;
  border-radius: var(--radius-sm);
  transition: background 0.15s;
  line-height: 1;
}
.feedback-btn:hover {
  background: var(--color-bg);
}

.feedback-thanks {
  font-size: 0.75rem;
  color: var(--color-text-muted);
}

.feedback-text-input {
  display: flex;
  align-items: center;
  gap: 6px;
}
.feedback-text-input input {
  padding: 4px 8px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  font-size: 0.75rem;
  outline: none;
  width: 180px;
}
.feedback-text-input input:focus {
  border-color: var(--color-primary);
}

/* ── Dataset Browser Layout ───────────────────────────────────────────────── */

.dataset-browser-layout {
  display: flex;
  flex-direction: column;
  height: 100vh;
}

.browser-main {
  display: flex;
  flex: 1;
  overflow: hidden;
}

.dataset-list-panel {
  width: 280px;
  min-width: 280px;
  border-right: 1px solid var(--color-border);
  background: var(--color-surface);
  padding: 20px;
  overflow-y: auto;
}
.dataset-list-panel h2 {
  font-size: 0.9375rem;
  font-weight: 700;
  margin-bottom: 12px;
}

.dataset-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.dataset-item {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 10px 12px;
  border: none;
  border-radius: var(--radius-sm);
  background: transparent;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s;
}
.dataset-item:hover {
  background: var(--color-bg);
}
.dataset-item.active {
  background: var(--color-primary-light);
  color: var(--color-primary);
}
.ds-name {
  font-size: 0.8125rem;
  font-weight: 500;
}
.ds-status {
  font-size: 0.6875rem;
  color: var(--color-text-muted);
}

.empty-hint {
  font-size: 0.8125rem;
  color: var(--color-text-muted);
  padding: 10px 12px;
}

.dataset-detail-panel {
  flex: 1;
  padding: 24px;
  overflow-y: auto;
}

.detail-header {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 12px;
}
.detail-header h2 {
  font-size: 1.25rem;
  font-weight: 700;
}

.badge {
  padding: 3px 10px;
  background: var(--color-primary-light);
  color: var(--color-primary);
  border-radius: 100px;
  font-size: 0.6875rem;
  font-weight: 600;
  text-transform: uppercase;
}

.detail-desc {
  font-size: 0.875rem;
  color: var(--color-text-secondary);
  margin-bottom: 16px;
}

.detail-stats {
  display: flex;
  gap: 24px;
  margin-bottom: 24px;
}
.stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
}
.stat-value {
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--color-primary);
}
.stat-label {
  font-size: 0.6875rem;
  color: var(--color-text-muted);
  text-transform: uppercase;
}

/* ── Discovery Tree ───────────────────────────────────────────────────────── */

.discovery-section {
  margin-top: 8px;
}
.discovery-section h3 {
  font-size: 1rem;
  font-weight: 700;
  margin-bottom: 12px;
}

.discovery-tree {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.discovery-node {
  display: flex;
  flex-direction: column;
}

.discovery-toggle {
  display: flex;
  align-items: center;
  gap: 8px;
  background: none;
  border: none;
  padding: 6px 8px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  text-align: left;
  transition: background 0.15s;
}
.discovery-toggle:hover {
  background: var(--color-bg);
}

.toggle-icon {
  font-size: 0.625rem;
  color: var(--color-text-muted);
  width: 14px;
  text-align: center;
}

.node-name {
  font-size: 0.8125rem;
  font-weight: 600;
}

.node-detail {
  padding-left: 22px;
  padding-top: 4px;
  padding-bottom: 4px;
}

.node-desc {
  font-size: 0.75rem;
  color: var(--color-text-secondary);
  margin-bottom: 8px;
}

.suggested-questions {
  margin-bottom: 8px;
}
.sq-label {
  font-size: 0.6875rem;
  font-weight: 600;
  color: var(--color-text-muted);
  text-transform: uppercase;
}
.suggested-questions ul {
  list-style: none;
  margin-top: 4px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.sq-link {
  font-size: 0.8125rem;
  color: var(--color-primary);
  text-decoration: none;
}
.sq-link:hover {
  text-decoration: underline;
}

/* ── Loading & Empty States ───────────────────────────────────────────────── */

.loading-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 60px 20px;
  gap: 12px;
  color: var(--color-text-muted);
  font-size: 0.875rem;
}

.spinner {
  width: 32px;
  height: 32px;
  border: 3px solid var(--color-border);
  border-top-color: var(--color-primary);
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.empty-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 80px 20px;
  text-align: center;
}
.empty-state h2 {
  font-size: 1.125rem;
  font-weight: 600;
  margin-bottom: 8px;
}
.empty-state p {
  font-size: 0.875rem;
  color: var(--color-text-secondary);
  max-width: 400px;
}

examples/03-document-search/frontend/tsconfig.json
JSON
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,
    "forceConsistentCasingInFileNames": true
  },
  "include": ["src"]
}

examples/03-document-search/frontend/vite.config.ts
TypeScript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    port: 5173,
  },
});

Example 04 - Gaia MCP Server

File Index

File Viewer

examples/04-gaia-mcp-server/.env.example
Text Only
# Required
GAIA_API_KEY=your-gaia-api-key

# Optional
GAIA_BASE_URL=https://helios.cohesity.com/v2/mcm/gaia
GAIA_TIMEOUT_SECONDS=60
GAIA_VERIFY_SSL=true

# Local server
PORT=8002

examples/04-gaia-mcp-server/Dockerfile
Docker
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py .

EXPOSE 8002

CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8002"]

examples/04-gaia-mcp-server/README.md
Markdown
# 04 - Gaia MCP Server

A standalone MCP (Model Context Protocol) server example for Cohesity Gaia.

This project exposes Gaia operations as MCP tools over **Streamable HTTP** so
clients like Cursor, Claude Desktop, and MCP Inspector can connect directly.

---

## What This Example Demonstrates

- Building an MCP server with `FastMCP`
- Exposing tools over Streamable HTTP at `/mcp`
- Calling Gaia APIs securely with a server-side API key
- Running locally with Uvicorn or in a Docker container

---

## Exposed Tools

| Tool | Description |
|---|---|
| `list_datasets` | List datasets available to your API key |
| `ask` | Run a standard RAG query against one or more datasets |
| `exhaustive_search` | Run paginated exhaustive document search |

---

## Prerequisites

- Python 3.10+
- A valid Cohesity Gaia API key
- Access to at least one indexed dataset

---

## Run Locally

```bash
cd examples/04-gaia-mcp-server

python -m venv .venv
source .venv/bin/activate

pip install -r requirements.txt

cp .env.example .env
# Edit .env and set GAIA_API_KEY

uvicorn server:app --host 0.0.0.0 --port 8002 --reload
```

Health endpoint:

```bash
curl http://localhost:8002/healthz
```

MCP endpoint:

```text
http://localhost:8002/mcp
```

---

## Test With MCP Inspector

```bash
npx -y @modelcontextprotocol/inspector
```

In Inspector:

- **Transport:** Streamable HTTP
- **Server URL:** `http://localhost:8002/mcp`

Then call:

1. `list_datasets`
2. `ask` with a dataset name and a question

---

## Docker Run

```bash
cd examples/04-gaia-mcp-server

docker build -t gaia-mcp-server-example .

docker run --rm -p 8002:8002 \
  -e GAIA_API_KEY="your-api-key" \
  -e GAIA_BASE_URL="https://helios.cohesity.com/v2/mcm/gaia" \
  gaia-mcp-server-example
```

---

## Cursor MCP Client Example

Example MCP server config:

```json
{
  "mcpServers": {
    "gaia-example": {
      "transport": {
        "type": "streamable-http",
        "url": "http://localhost:8002/mcp"
      }
    }
  }
}
```

Once connected, ask:

- "List my Gaia datasets"
- "Ask Gaia what changed in last quarter's finance reports"

---

## Notes

- This example uses a **server-side** `GAIA_API_KEY`. For multi-user production
  scenarios, add per-user authentication and secret management.
- In production, prefer managed identity + Key Vault or another secure secret
  store for API keys.

examples/04-gaia-mcp-server/requirements.txt
Text Only
fastapi
uvicorn[standard]
httpx
python-dotenv
mcp

examples/04-gaia-mcp-server/server.py
Python
"""Example Gaia MCP server (Streamable HTTP).

This example shows how to expose Cohesity Gaia operations as MCP tools so
clients like Cursor, Claude Desktop, and MCP Inspector can call them.
"""

from __future__ import annotations

import contextlib
import os
from typing import Any

import httpx
from dotenv import load_dotenv
from fastapi import FastAPI
from mcp.server.fastmcp import FastMCP

load_dotenv()


def _required_env(name: str) -> str:
    value = os.getenv(name, "").strip()
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value


def _gaia_base_url() -> str:
    return os.getenv("GAIA_BASE_URL", "https://helios.cohesity.com/v2/mcm/gaia").rstrip("/")


def _gaia_headers() -> dict[str, str]:
    return {
        "apiKey": _required_env("GAIA_API_KEY"),
        "Content-Type": "application/json",
        "Accept": "application/json",
    }


def _gaia_timeout() -> float:
    try:
        return float(os.getenv("GAIA_TIMEOUT_SECONDS", "60"))
    except ValueError:
        return 60.0


def _gaia_verify_ssl() -> bool:
    return os.getenv("GAIA_VERIFY_SSL", "true").lower() in {"1", "true", "yes", "on"}


def _gaia_request(
    method: str,
    path: str,
    *,
    params: dict[str, Any] | None = None,
    json: dict[str, Any] | None = None,
) -> dict[str, Any]:
    url = f"{_gaia_base_url()}{path}"
    with httpx.Client(timeout=_gaia_timeout(), verify=_gaia_verify_ssl()) as client:
        response = client.request(method, url, headers=_gaia_headers(), params=params, json=json)

    if response.status_code >= 400:
        try:
            payload = response.json()
            message = payload.get("message") or payload.get("error") or response.text
        except Exception:
            message = response.text
        raise RuntimeError(f"Gaia API returned {response.status_code}: {message}")

    return response.json()


mcp = FastMCP(
    "Build With Gaia MCP Server",
    json_response=True,
    streamable_http_path="/",
    host="0.0.0.0",
)


@mcp.tool()
def list_datasets(search_term: str | None = None, page_size: int = 20) -> dict[str, Any]:
    """List datasets available to this Gaia API key."""
    params: dict[str, Any] = {"pageSize": page_size}
    if search_term:
        params["datasetsSearchTerm"] = search_term
    return _gaia_request("GET", "/datasets", params=params)


@mcp.tool()
def ask(
    question: str,
    dataset_names: list[str],
    conversation_id: str | None = None,
    llm_name: str | None = None,
) -> dict[str, Any]:
    """Ask a RAG question against one or more datasets."""
    body: dict[str, Any] = {
        "queryString": question,
        "datasetNames": dataset_names,
    }
    if conversation_id:
        body["conversationId"] = conversation_id
    if llm_name:
        body["llmName"] = llm_name
    return _gaia_request("POST", "/ask", json=body)


@mcp.tool()
def exhaustive_search(
    query_string: str,
    dataset_name: str,
    page_size: int = 20,
    pagination_token: str | None = None,
) -> dict[str, Any]:
    """Run exhaustive search for full document retrieval."""
    body: dict[str, Any] = {
        "queryString": query_string,
        "datasetName": dataset_name,
        "pageSize": page_size,
    }
    if pagination_token:
        body["paginationToken"] = pagination_token
    return _gaia_request("PUT", "/ask/exhaustive", json=body)


@contextlib.asynccontextmanager
async def lifespan(_: FastAPI):
    # FastMCP requires a running session manager for Streamable HTTP.
    async with mcp.session_manager.run():
        yield


app = FastAPI(title="Build With Gaia MCP Server", version="0.1.0", lifespan=lifespan)
app.mount("/mcp", mcp.streamable_http_app())


@app.get("/healthz")
def healthz() -> dict[str, str]:
    return {"status": "ok"}


@app.get("/")
def root() -> dict[str, str]:
    return {"service": "build-with-gaia-mcp-server", "mcp": "/mcp", "health": "/healthz"}

Example 05 - Marketplace Chat App

File Index

File Viewer

examples/05-marketplace-chat/.env.example
Text Only
# Required: Gaia API key
# In Marketplace deployments this is set in appspec.yaml, not here.
GAIA_API_KEY=your-gaia-api-key-here

# Optional overrides
GAIA_BASE_URL=https://helios.cohesity.com/v2/mcm/gaia
GAIA_VERIFY_SSL=true
REQUEST_TIMEOUT_SECONDS=60

# Development only: enables CORS so the Vite dev server can reach FastAPI
ALLOW_CORS_ORIGIN=http://localhost:5175

examples/05-marketplace-chat/Dockerfile
Docker
# Gaia Chat — Marketplace Edition
# Multi-stage build: compiles the React frontend, then bundles it with FastAPI.
# The result is a single container that serves both the API and the UI.

# ── Stage 1: Build the React frontend ────────────────────────────────────────
FROM node:20-slim AS frontend-builder

WORKDIR /app/frontend

# Install dependencies first (layer cache friendly)
COPY frontend/package*.json ./
RUN npm ci

# Build the production bundle
COPY frontend/ ./
RUN npm run build
# Output is in /app/frontend/dist

# ── Stage 2: Python backend + static files ────────────────────────────────────
FROM python:3.11-slim

WORKDIR /app

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY main.py settings.py ./
COPY api/ ./api/

# Copy the compiled React bundle into the `static/` directory.
# FastAPI's StaticFiles mount serves this for all non-API routes.
COPY --from=frontend-builder /app/frontend/dist ./static/

# Copy and make the crash-recovery wrapper executable
COPY wrapper.sh .
RUN chmod +x wrapper.sh

# The Marketplace platform exposes this port via the NodePort service.
EXPOSE 8080

# Use wrapper.sh for automatic restart on crash (Marketplace best practice).
CMD ["./wrapper.sh"]

examples/05-marketplace-chat/README.md
Markdown
# Example 05 — Marketplace Chat App

A full-stack chat application packaged for deployment on the **Cohesity App Marketplace**. This example demonstrates the single-container pattern: a React frontend and FastAPI backend built into one Docker image, with authentication handled by the Marketplace platform rather than by the application itself.

---

## What This Example Demonstrates

- **Single-container packaging** — React is compiled at image build time; FastAPI serves the static files and the API from the same process.
- **Marketplace auth model** — No login page. The API key is configured in `appspec.yaml` by the cluster admin and injected as an environment variable at runtime.
- **Cohesity token gate** — The `cohesityTag: ui` annotation in `appspec.yaml` makes the Marketplace enforce Cohesity user authentication before any request reaches the container.
- **App SDK environment variables** — Reading `HOST_IP`, `APP_AUTHENTICATION_TOKEN`, and other Marketplace-injected vars from the environment.
- **SSE streaming** — Real-time Gaia responses streamed from the FastAPI backend to the React frontend via Server-Sent Events.

---

## Architecture

```
Cohesity Cluster
┌─────────────────────────────────────────────────────────┐
│                                                         │
│  Marketplace Token Gate  ──▶  NodePort Service          │
│                                    │                    │
│                               Pod (port 8080)           │
│                          ┌────────────────────┐         │
│                          │  gaia-chat:latest  │         │
│                          │                    │         │
│                          │  FastAPI           │──▶ Gaia │
│                          │   ├── /api/*       │   API   │
│                          │   └── /* (React)   │         │
│                          └────────────────────┘         │
└─────────────────────────────────────────────────────────┘
```

The React app and FastAPI backend run in the same container. React is compiled during the Docker build and served as static files by FastAPI's `StaticFiles` mount. No Nginx or separate web server is needed.

---

## File Structure

```
examples/05-marketplace-chat/
├── main.py               # FastAPI app: serves API + React static files
├── settings.py           # Pydantic Settings: Gaia config + App SDK env vars
├── api/
│   ├── routes.py         # /datasets, /ask/stream, /conversations, /status
│   └── models.py         # AskRequest Pydantic model
├── requirements.txt      # Python dependencies
├── .env.example          # Local development config
├── frontend/             # React + TypeScript app
│   ├── src/
│   │   ├── api/
│   │   │   ├── client.ts       # fetch wrapper
│   │   │   └── gaia.ts         # Gaia API calls + SSE streaming
│   │   ├── state/
│   │   │   └── chatStore.ts    # Zustand state (no session concept)
│   │   ├── pages/
│   │   │   └── ChatPage.tsx    # Main chat interface (no login)
│   │   ├── components/
│   │   │   ├── ChatMessage.tsx
│   │   │   ├── ChatInput.tsx
│   │   │   └── DatasetSelector.tsx
│   │   └── styles/app.css
│   ├── package.json
│   ├── vite.config.ts          # Proxies /api → :8080 in dev
│   └── index.html
├── Dockerfile            # Multi-stage: Node → Python (no login page)
├── wrapper.sh            # Crash-recovery restart loop
├── appspec.yaml          # Cohesity Marketplace deployment manifest
└── app.json              # Marketplace app metadata
```

---

## Key Differences From Example 02 (Chat App)

| Feature | Example 02 | Example 05 |
|---------|-----------|------------|
| Auth | Login page (API key from user) | No login — key from appspec.yaml |
| Frontend | Separate Vite dev server | Compiled into Docker image |
| Deployment | Docker Compose (local) | Cohesity Marketplace |
| Port | 8000 (configurable) | 8080 (fixed by appspec) |
| Auth enforcement | Application-level session | Cohesity Marketplace token gate |

---

## Local Development

```bash
cd examples/05-marketplace-chat

# Backend
cp .env.example .env
# Edit .env and set GAIA_API_KEY

pip install -r requirements.txt
uvicorn main:app --reload --port 8080

# Frontend (separate terminal)
cd frontend
npm install
npm run dev   # Proxies /api to :8080
```

Open `http://localhost:5173` — the Vite dev server proxies API calls to the FastAPI backend.

---

## Building the Docker Image

```bash
cd examples/05-marketplace-chat

docker build -t gaia-chat:latest .
docker run -p 8080:8080 \
  -e GAIA_API_KEY="your-api-key" \
  gaia-chat:latest
```

Open `http://localhost:8080`.

---

## Deploying to the Cohesity Marketplace

### Prerequisites

- Cohesity cluster running version **7.2 or later** (Gaia required)
- Access to the Cohesity DevPortal to upload the app package
- A Gaia API key (from Cohesity Helios)

### Steps

1. **Build and tag the image:**
   ```bash
   docker build -t gaia-chat:latest .
   ```

2. **Update `appspec.yaml`** — replace `YOUR_GAIA_API_KEY` with your actual API key.

3. **Package the app:**
   ```bash
   # Cohesity Marketplace packages are a tar of: appspec.yaml, app.json, and the image
   docker save gaia-chat:latest -o gaia-chat.tar
   tar -czf gaia-chat-app.tar.gz appspec.yaml app.json gaia-chat.tar
   ```

4. **Validate the appspec:**
   ```bash
   ./appspecvalidator_exec appspec.yaml
   ```

5. **Upload to DevPortal** at your Cohesity cluster UI → Apps → Upload.

### What Happens After Deployment

- The Marketplace assigns a NodePort and exposes the app through the token gate.
- Cluster users see the app in the Apps dashboard and click **Launch**.
- The Cohesity token gate authenticates the user before forwarding requests to the container.
- The container reads `GAIA_API_KEY` from the environment (set in `appspec.yaml`) and proxies all Gaia calls on behalf of the user.

---

## Environment Variables

| Variable | Source | Purpose |
|----------|--------|---------|
| `GAIA_API_KEY` | `appspec.yaml` env block | Required — authenticates with Gaia |
| `GAIA_BASE_URL` | `appspec.yaml` / `.env` | Override Helios URL |
| `GAIA_VERIFY_SSL` | `appspec.yaml` / `.env` | Set `false` for dev/self-signed |
| `REQUEST_TIMEOUT_SECONDS` | `appspec.yaml` / `.env` | Request timeout in seconds (default 60) |
| `HOST_IP` | Auto-injected by Marketplace | Cluster node IP |
| `APP_AUTHENTICATION_TOKEN` | Auto-injected by Marketplace | Confirms Marketplace runtime |
| `ALLOW_CORS_ORIGIN` | Optional, dev only | Enable CORS for Vite dev server |

---

## Minimum Cluster Version

**Cohesity 7.2** — Gaia must be enabled on the cluster. Earlier versions do not have the Gaia API endpoints required by this application.

examples/05-marketplace-chat/api/__init__.py
Python

examples/05-marketplace-chat/api/models.py
Python
"""Pydantic request/response models."""

from __future__ import annotations

from typing import Optional

from pydantic import BaseModel, Field


class AskRequest(BaseModel):
    query: str
    dataset_names: list[str] = Field(alias="datasetNames")
    conversation_id: Optional[str] = Field(None, alias="conversationId")

    model_config = {"populate_by_name": True}

examples/05-marketplace-chat/api/routes.py
Python
"""API routes — no auth gates.

In Marketplace mode, the Cohesity platform handles user authentication via the
cohesityTag: ui token gate. The GAIA_API_KEY is pre-configured in the appspec
and never exposed to the browser.
"""

from __future__ import annotations

from fastapi import APIRouter
from fastapi.responses import StreamingResponse

from gaia_sdk import GaiaClient
from gaia_sdk.exceptions import GaiaError
from settings import get_settings

from .models import AskRequest

router = APIRouter()


def _make_client() -> GaiaClient:
    s = get_settings()
    return GaiaClient(
        api_key=s.gaia_api_key,
        base_url=s.gaia_base_url,
        timeout=s.request_timeout_seconds,
        verify_ssl=s.gaia_verify_ssl,
    )


# ── Datasets ──────────────────────────────────────────────────────────────────

@router.get("/datasets", tags=["Datasets"])
async def list_datasets():
    """List datasets accessible to the configured API key."""
    async with _make_client() as gaia:
        datasets = await gaia.list_datasets()
    return {"datasets": [d.model_dump(by_alias=True) for d in datasets]}


# ── Ask ───────────────────────────────────────────────────────────────────────

@router.post("/ask", tags=["Ask"])
async def ask(body: AskRequest):
    """Non-streaming RAG query."""
    async with _make_client() as gaia:
        result = await gaia.ask(
            dataset_names=body.dataset_names,
            query=body.query,
            conversation_id=body.conversation_id,
        )
    return result.model_dump(by_alias=True)


@router.post("/ask/stream", tags=["Ask"])
async def ask_stream(body: AskRequest):
    """Streaming RAG query — SSE proxied from Gaia."""

    async def event_generator():
        try:
            async with _make_client() as gaia:
                async for chunk in gaia.ask_stream_iter(
                    dataset_names=body.dataset_names,
                    query=body.query,
                    conversation_id=body.conversation_id,
                ):
                    if chunk.event and chunk.event != "message":
                        yield f"event: {chunk.event}\n"
                    yield f"data: {chunk.data}\n\n"
        except GaiaError:
            yield "data: [ERROR]\n\n"

    return StreamingResponse(
        event_generator(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )


# ── Conversations ─────────────────────────────────────────────────────────────

@router.get("/conversations", tags=["Conversations"])
async def list_conversations():
    async with _make_client() as gaia:
        return await gaia.list_conversations()


@router.get("/conversations/{conversation_id}/history", tags=["Conversations"])
async def get_chat_history(conversation_id: str):
    async with _make_client() as gaia:
        return await gaia.get_chat_history(conversation_id)


# ── System ────────────────────────────────────────────────────────────────────

@router.get("/status", tags=["System"])
async def status():
    """Returns app context — useful for debugging in Marketplace environments."""
    s = get_settings()
    return {
        "app": "gaia-marketplace-chat",
        "version": "1.0.0",
        "marketplace": s.is_marketplace,
        "host_ip": s.host_ip or None,
    }

examples/05-marketplace-chat/app.json
JSON
{
  "id": 1,
  "name": "Gaia Chat",
  "version": 1,
  "dev_version": 1.0,
  "description": "Conversational RAG interface powered by Cohesity Gaia. Ask questions across your knowledge bases and get AI-generated answers grounded in your data.",
  "access_requirements": {
    "read_access": false,
    "read_write_access": false,
    "management_access": false,
    "auto_mount_access": false,
    "unrestricted_app_ui_access": false
  }
}

examples/05-marketplace-chat/appspec.yaml
YAML
## Gaia Chat — Cohesity Marketplace AppSpec
## Minimum cluster version: 7.2 (requires Gaia)
##
## Before deploying, replace YOUR_GAIA_API_KEY with a real Gaia API key.
## Validate this file with: ./appspecvalidator_exec appspec.yaml

apiVersion: v1
kind: Service
metadata:
  name: gaia-chat-service
  labels:
    app: gaia-chat
spec:
  type: NodePort
  selector:
    app: gaia-chat
  ports:
    - port: 8080
      protocol: TCP
      name: ui
      cohesityTag: ui      # Exposes this port in the Cohesity Marketplace UI.
                            # Access is protected by Cohesity's token gate —
                            # users must be authenticated to the cluster.

---

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: gaia-chat
  labels:
    app: gaia-chat
spec:
  replicas:
    fixed: 1
  selector:
    matchLabels:
      app: gaia-chat
  template:
    metadata:
      labels:
        app: gaia-chat
    spec:
      containers:
        - name: gaia-chat
          image: gaia-chat:latest
          resources:
            requests:
              cpu: 500m
              memory: 256Mi
          env:
            - name: GAIA_API_KEY
              value: "YOUR_GAIA_API_KEY"           # ← replace this
            - name: GAIA_BASE_URL
              value: "https://helios.cohesity.com/v2/mcm/gaia"
            - name: GAIA_VERIFY_SSL
              value: "true"
            - name: REQUEST_TIMEOUT_SECONDS
              value: "60"

## ── Notes ────────────────────────────────────────────────────────────────────
##
## Cohesity automatically injects the following env vars at runtime —
## you do NOT need to define them here:
##
##   HOST_IP                  — IP of the physical node running the container
##   APPS_API_ENDPOINT_IP     — Apps API gateway IP
##   APPS_API_ENDPOINT_PORT   — Apps API gateway port
##   APP_AUTHENTICATION_TOKEN — App SDK bearer token
##   POD_UID                  — Unique pod identifier
##
## The app reads these in settings.py to detect when it's running on a cluster.

examples/05-marketplace-chat/frontend/index.html
HTML
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Gaia Chat</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

examples/05-marketplace-chat/frontend/package.json
JSON
{
  "name": "gaia-marketplace-chat",
  "private": true,
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "react": "^18.3.1",
    "react-dom": "^18.3.1",
    "react-markdown": "^9.0.1",
    "zustand": "^4.5.0"
  },
  "devDependencies": {
    "@types/react": "^18.3.3",
    "@types/react-dom": "^18.3.0",
    "@vitejs/plugin-react": "^4.3.1",
    "typescript": "^5.5.3",
    "vite": "^5.4.2"
  }
}

examples/05-marketplace-chat/frontend/src/App.tsx
TSX
// No router needed — the Marketplace token gate and Cohesity UI handle
// navigation. We go straight to the chat interface.
import { ChatPage } from './pages/ChatPage'

function App() {
  return <ChatPage />
}

export default App

examples/05-marketplace-chat/frontend/src/api/client.ts
TypeScript
/**
 * Base API client — no session management needed.
 *
 * In Marketplace mode, the user is already authenticated by the Cohesity
 * token gate before they reach the app UI. The browser session is implicit.
 *
 * In local development the Vite proxy (vite.config.ts) forwards /api
 * requests to FastAPI on port 8080.
 */

const API_BASE = '/api/v1'

export class ApiError extends Error {
  constructor(
    public readonly status: number,
    message: string
  ) {
    super(message)
    this.name = 'ApiError'
  }
}

export async function apiFetch<T>(
  path: string,
  options: RequestInit = {}
): Promise<T> {
  const response = await fetch(`${API_BASE}${path}`, {
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
    },
    ...options,
  })

  if (!response.ok) {
    const data = await response.json().catch(() => ({}))
    throw new ApiError(response.status, data.detail ?? `HTTP ${response.status}`)
  }

  return response.json()
}

examples/05-marketplace-chat/frontend/src/api/gaia.ts
TypeScript
import { apiFetch } from './client'

export interface Dataset {
  name: string
  status?: string
  description?: string
  objectCount?: number
}

export interface GaiaDocument {
  docId?: string
  filename?: string
  filepath?: string
  snippet?: string
  score?: number
}

export async function listDatasets(): Promise<Dataset[]> {
  const data = await apiFetch<{ datasets: Dataset[] }>('/datasets')
  return data.datasets ?? []
}

export interface AskStreamParams {
  query: string
  datasetNames: string[]
  conversationId?: string
  onChunk: (text: string) => void
  onDone: (meta: { queryUid?: string; conversationId?: string; documents?: GaiaDocument[] }) => void
  onError: (message: string) => void
}

/**
 * Streaming RAG query using the SSE endpoint.
 * Returns an AbortController — call .abort() to cancel mid-stream.
 */
export function askStream(params: AskStreamParams): AbortController {
  const controller = new AbortController()

  ;(async () => {
    try {
      const response = await fetch('/api/v1/ask/stream', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          query: params.query,
          datasetNames: params.datasetNames,
          conversationId: params.conversationId,
        }),
        signal: controller.signal,
      })

      if (!response.ok) {
        params.onError(`Request failed: ${response.status}`)
        return
      }

      const reader = response.body!.getReader()
      const decoder = new TextDecoder()
      let buffer = ''
      let queryUid: string | undefined
      let conversationId: string | undefined
      let documents: GaiaDocument[] = []

      while (true) {
        const { done, value } = await reader.read()
        if (done) break

        buffer += decoder.decode(value, { stream: true })
        const lines = buffer.split('\n')
        buffer = lines.pop() ?? ''

        for (const line of lines) {
          if (!line.startsWith('data: ')) continue
          const data = line.slice(6).trim()
          if (!data) continue
          if (data === '[ERROR]') {
            params.onError('Streaming error from server')
            return
          }
          try {
            const parsed = JSON.parse(data)
            if (parsed.responseString) params.onChunk(parsed.responseString)
            if (parsed.queryUid) queryUid = parsed.queryUid
            if (parsed.conversationId) conversationId = parsed.conversationId
            if (parsed.documents?.length) documents = parsed.documents
          } catch {
            // Plain text token from the stream
            params.onChunk(data)
          }
        }
      }

      params.onDone({ queryUid, conversationId, documents })
    } catch (err: unknown) {
      if (err instanceof Error && err.name !== 'AbortError') {
        params.onError(err.message)
      }
    }
  })()

  return controller
}

examples/05-marketplace-chat/frontend/src/components/ChatInput.tsx
TSX
import { useState, KeyboardEvent } from 'react'
import { useChatStore } from '../state/chatStore'

interface Props {
  disabled: boolean
  placeholder?: string
}

export function ChatInput({ disabled, placeholder }: Props) {
  const [value, setValue] = useState('')
  const sendMessage = useChatStore((s) => s.sendMessage)

  const handleSubmit = () => {
    if (!value.trim() || disabled) return
    sendMessage(value.trim())
    setValue('')
  }

  const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
    if (e.key === 'Enter' && !e.shiftKey) {
      e.preventDefault()
      handleSubmit()
    }
  }

  return (
    <div className="input-area">
      <textarea
        className="chat-input"
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={handleKeyDown}
        placeholder={placeholder ?? 'Ask a question…'}
        disabled={disabled}
        rows={3}
      />
      <button
        className="btn-primary send-btn"
        onClick={handleSubmit}
        disabled={disabled || !value.trim()}
      >
        Send
      </button>
    </div>
  )
}

examples/05-marketplace-chat/frontend/src/components/ChatMessage.tsx
TSX
import ReactMarkdown from 'react-markdown'
import { ChatMessage as ChatMessageType } from '../state/chatStore'

export function ChatMessage({ message }: { message: ChatMessageType }) {
  return (
    <div className={`message message--${message.role}`}>
      <div className="message-bubble">
        <ReactMarkdown>{message.content || '\u00A0'}</ReactMarkdown>
        {message.isStreaming && <span className="streaming-cursor" aria-hidden />}
      </div>

      {message.documents && message.documents.length > 0 && (
        <div className="sources">
          <span className="sources-label">Sources</span>
          {message.documents.map((doc, i) => (
            <span
              key={doc.docId ?? i}
              className="source-chip"
              title={doc.snippet ?? doc.filepath ?? ''}
            >
              {doc.filename ?? doc.docId ?? `Source ${i + 1}`}
            </span>
          ))}
        </div>
      )}
    </div>
  )
}

examples/05-marketplace-chat/frontend/src/components/DatasetSelector.tsx
TSX
import { Dataset } from '../api/gaia'

interface Props {
  datasets: Dataset[]
  selected: string[]
  onChange: (selected: string[]) => void
}

export function DatasetSelector({ datasets, selected, onChange }: Props) {
  const toggle = (name: string) => {
    onChange(
      selected.includes(name)
        ? selected.filter((n) => n !== name)
        : [...selected, name]
    )
  }

  if (datasets.length === 0) {
    return <p className="muted">No datasets available.</p>
  }

  return (
    <ul className="dataset-list">
      {datasets.map((d) => (
        <li key={d.name}>
          <label className={`dataset-item ${selected.includes(d.name) ? 'dataset-item--active' : ''}`}>
            <input
              type="checkbox"
              checked={selected.includes(d.name)}
              onChange={() => toggle(d.name)}
            />
            <span className="dataset-name">{d.name}</span>
            {d.status && (
              <span className={`dataset-status dataset-status--${d.status.toLowerCase()}`}>
                {d.status}
              </span>
            )}
          </label>
        </li>
      ))}
    </ul>
  )
}

examples/05-marketplace-chat/frontend/src/main.tsx
TSX
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './styles/app.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
)

examples/05-marketplace-chat/frontend/src/pages/ChatPage.tsx
TSX
import { useEffect, useState } from 'react'
import { listDatasets, Dataset } from '../api/gaia'
import { useChatStore } from '../state/chatStore'
import { ChatMessage } from '../components/ChatMessage'
import { ChatInput } from '../components/ChatInput'
import { DatasetSelector } from '../components/DatasetSelector'

export function ChatPage() {
  const [datasets, setDatasets] = useState<Dataset[]>([])
  const [loadingDatasets, setLoadingDatasets] = useState(true)
  const [fetchError, setFetchError] = useState<string | undefined>()

  const {
    messages,
    selectedDatasets,
    isStreaming,
    error,
    setSelectedDatasets,
    newConversation,
    clearError,
  } = useChatStore()

  useEffect(() => {
    listDatasets()
      .then(setDatasets)
      .catch((e: Error) => setFetchError(e.message))
      .finally(() => setLoadingDatasets(false))
  }, [])

  return (
    <div className="app-layout">
      {/* ── Sidebar ──────────────────────────────────────────────── */}
      <aside className="sidebar">
        <div className="sidebar-header">
          <span className="brand-name">Gaia Chat</span>
          <span className="brand-badge">Marketplace</span>
        </div>

        <div className="sidebar-section">
          <p className="sidebar-label">Datasets</p>
          {loadingDatasets ? (
            <p className="muted">Loading...</p>
          ) : fetchError ? (
            <p className="error-text">{fetchError}</p>
          ) : (
            <DatasetSelector
              datasets={datasets}
              selected={selectedDatasets}
              onChange={setSelectedDatasets}
            />
          )}
        </div>

        <div className="sidebar-footer">
          <button className="btn-ghost" onClick={newConversation}>
            + New conversation
          </button>
        </div>
      </aside>

      {/* ── Main chat area ───────────────────────────────────────── */}
      <main className="chat-area">
        <div className="messages-scroll">
          {messages.length === 0 ? (
            <div className="empty-state">
              <p className="empty-title">Ask anything</p>
              <p className="empty-sub">
                Select one or more datasets from the sidebar, then type a question.
              </p>
            </div>
          ) : (
            <div className="messages-list">
              {messages.map((m) => (
                <ChatMessage key={m.id} message={m} />
              ))}
            </div>
          )}
        </div>

        {(error) && (
          <div className="error-banner" onClick={clearError}>
            {error} <span className="dismiss"></span>
          </div>
        )}

        <ChatInput
          disabled={isStreaming || selectedDatasets.length === 0}
          placeholder={
            selectedDatasets.length === 0
              ? 'Select a dataset to start chatting…'
              : 'Ask a question… (Enter to send, Shift+Enter for newline)'
          }
        />
      </main>
    </div>
  )
}

examples/05-marketplace-chat/frontend/src/state/chatStore.ts
TypeScript
import { create } from 'zustand'
import { askStream, GaiaDocument } from '../api/gaia'

export interface ChatMessage {
  id: string
  role: 'user' | 'assistant'
  content: string
  isStreaming?: boolean
  documents?: GaiaDocument[]
  queryUid?: string
}

interface ChatState {
  messages: ChatMessage[]
  conversationId: string | undefined
  selectedDatasets: string[]
  isStreaming: boolean
  error: string | undefined

  setSelectedDatasets: (datasets: string[]) => void
  sendMessage: (query: string) => void
  newConversation: () => void
  clearError: () => void
}

export const useChatStore = create<ChatState>((set, get) => ({
  messages: [],
  conversationId: undefined,
  selectedDatasets: [],
  isStreaming: false,
  error: undefined,

  setSelectedDatasets: (datasets) => set({ selectedDatasets: datasets }),

  clearError: () => set({ error: undefined }),

  newConversation: () =>
    set({ messages: [], conversationId: undefined, error: undefined }),

  sendMessage: (query) => {
    const { selectedDatasets, conversationId, isStreaming } = get()
    if (isStreaming || !query.trim() || selectedDatasets.length === 0) return

    const userMsgId = crypto.randomUUID()
    const assistantMsgId = crypto.randomUUID()

    set((s) => ({
      messages: [
        ...s.messages,
        { id: userMsgId, role: 'user', content: query },
        { id: assistantMsgId, role: 'assistant', content: '', isStreaming: true },
      ],
      isStreaming: true,
      error: undefined,
    }))

    askStream({
      query,
      datasetNames: selectedDatasets,
      conversationId,

      onChunk: (text) =>
        set((s) => ({
          messages: s.messages.map((m) =>
            m.id === assistantMsgId ? { ...m, content: m.content + text } : m
          ),
        })),

      onDone: ({ queryUid, conversationId: newConvId, documents }) =>
        set((s) => ({
          messages: s.messages.map((m) =>
            m.id === assistantMsgId
              ? { ...m, isStreaming: false, queryUid, documents: documents ?? [] }
              : m
          ),
          conversationId: newConvId ?? s.conversationId,
          isStreaming: false,
        })),

      onError: (err) =>
        set((s) => ({
          messages: s.messages.map((m) =>
            m.id === assistantMsgId
              ? { ...m, content: 'An error occurred. Please try again.', isStreaming: false }
              : m
          ),
          isStreaming: false,
          error: err,
        })),
    })
  },
}))

examples/05-marketplace-chat/frontend/src/styles/app.css
CSS
/* ── Reset & base ──────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }

:root {
  --bg-base:      #0f1117;
  --bg-surface:   #1a1f2e;
  --bg-elevated:  #242938;
  --bg-hover:     #2d3448;
  --border:       #2d3448;
  --border-focus: #7c3aed;

  --text-primary:   #e2e8f0;
  --text-secondary: #94a3b8;
  --text-muted:     #64748b;

  --accent:       #7c3aed;
  --accent-light: #a78bfa;
  --accent-dim:   #3b2170;
  --teal:         #0d9488;
  --teal-light:   #2dd4bf;

  --danger:       #ef4444;
  --success:      #22c55e;

  --radius-sm: 6px;
  --radius-md: 10px;
  --radius-lg: 16px;

  --sidebar-width: 260px;
  --font-mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
}

html, body, #root {
  height: 100%;
  overflow: hidden;
  background: var(--bg-base);
  color: var(--text-primary);
  font-family: -apple-system, BlinkMacSystemFont, 'Inter', 'Segoe UI', sans-serif;
  font-size: 15px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

/* ── Layout ────────────────────────────────────────────────────────────────── */
.app-layout {
  display: flex;
  height: 100vh;
  overflow: hidden;
}

/* ── Sidebar ───────────────────────────────────────────────────────────────── */
.sidebar {
  width: var(--sidebar-width);
  flex-shrink: 0;
  background: var(--bg-surface);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.sidebar-header {
  padding: 20px 16px 16px;
  border-bottom: 1px solid var(--border);
  display: flex;
  align-items: center;
  gap: 8px;
}

.brand-name {
  font-size: 17px;
  font-weight: 700;
  color: var(--accent-light);
  letter-spacing: -0.3px;
}

.brand-badge {
  font-size: 10px;
  font-weight: 600;
  background: var(--accent-dim);
  color: var(--accent-light);
  padding: 2px 7px;
  border-radius: 20px;
  letter-spacing: 0.5px;
  text-transform: uppercase;
}

.sidebar-section {
  flex: 1;
  padding: 16px;
  overflow-y: auto;
}

.sidebar-label {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.8px;
  color: var(--text-muted);
  margin-bottom: 10px;
}

.sidebar-footer {
  padding: 12px 16px;
  border-top: 1px solid var(--border);
}

/* ── Dataset list ──────────────────────────────────────────────────────────── */
.dataset-list {
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.dataset-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 10px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  transition: background 0.15s;
  border: 1px solid transparent;
}

.dataset-item:hover { background: var(--bg-hover); }

.dataset-item--active {
  background: var(--accent-dim);
  border-color: var(--accent);
}

.dataset-item input[type="checkbox"] {
  width: 15px;
  height: 15px;
  accent-color: var(--accent);
  flex-shrink: 0;
}

.dataset-name {
  font-size: 13px;
  font-weight: 500;
  color: var(--text-primary);
  flex: 1;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.dataset-status {
  font-size: 10px;
  font-weight: 600;
  padding: 2px 6px;
  border-radius: 10px;
  text-transform: uppercase;
}
.dataset-status--active, .dataset-status--ready { background: #14532d; color: var(--success); }
.dataset-status--indexing { background: #422006; color: #fbbf24; }
.dataset-status--error { background: #450a0a; color: var(--danger); }

/* ── Main chat area ────────────────────────────────────────────────────────── */
.chat-area {
  flex: 1;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--bg-base);
}

.messages-scroll {
  flex: 1;
  overflow-y: auto;
  padding: 24px 20px 12px;
  display: flex;
  flex-direction: column;
}

.messages-list {
  display: flex;
  flex-direction: column;
  gap: 20px;
  max-width: 860px;
  width: 100%;
  margin: 0 auto;
}

/* ── Empty state ───────────────────────────────────────────────────────────── */
.empty-state {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 10px;
  padding: 40px;
}

.empty-title {
  font-size: 22px;
  font-weight: 600;
  color: var(--text-secondary);
}

.empty-sub {
  font-size: 14px;
  color: var(--text-muted);
  max-width: 380px;
}

/* ── Chat messages ─────────────────────────────────────────────────────────── */
.message {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.message--user { align-items: flex-end; }
.message--assistant { align-items: flex-start; }

.message-bubble {
  max-width: 78%;
  padding: 12px 16px;
  border-radius: var(--radius-md);
  line-height: 1.65;
  position: relative;
}

.message--user .message-bubble {
  background: var(--accent-dim);
  border: 1px solid var(--accent);
  color: var(--text-primary);
  border-bottom-right-radius: var(--radius-sm);
}

.message--assistant .message-bubble {
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  color: var(--text-primary);
  border-bottom-left-radius: var(--radius-sm);
}

/* Markdown inside bubbles */
.message-bubble p { margin-bottom: 0.5em; }
.message-bubble p:last-child { margin-bottom: 0; }
.message-bubble code {
  font-family: var(--font-mono);
  font-size: 13px;
  background: rgba(0,0,0,0.3);
  padding: 1px 5px;
  border-radius: 3px;
}
.message-bubble pre {
  background: rgba(0,0,0,0.4);
  padding: 12px;
  border-radius: var(--radius-sm);
  overflow-x: auto;
  margin: 8px 0;
}
.message-bubble pre code { background: none; padding: 0; }
.message-bubble ul, .message-bubble ol { padding-left: 20px; margin: 4px 0; }

/* Streaming cursor */
.streaming-cursor {
  display: inline-block;
  width: 2px;
  height: 1.1em;
  background: var(--accent-light);
  margin-left: 2px;
  vertical-align: text-bottom;
  animation: blink 0.9s step-end infinite;
}
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }

/* ── Sources ───────────────────────────────────────────────────────────────── */
.sources {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  padding-left: 4px;
}

.sources-label {
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--text-muted);
}

.source-chip {
  font-size: 11px;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  color: var(--teal-light);
  padding: 2px 8px;
  border-radius: 10px;
  cursor: default;
  white-space: nowrap;
  max-width: 200px;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ── Input area ────────────────────────────────────────────────────────────── */
.input-area {
  display: flex;
  align-items: flex-end;
  gap: 10px;
  padding: 12px 20px 16px;
  border-top: 1px solid var(--border);
  background: var(--bg-surface);
  max-width: 860px;
  width: 100%;
  margin: 0 auto;
  align-self: center;
  box-sizing: border-box;
  /* stretch to full width */
  width: 100%;
}

.chat-input {
  flex: 1;
  background: var(--bg-elevated);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  color: var(--text-primary);
  font-size: 14px;
  line-height: 1.5;
  padding: 10px 14px;
  resize: none;
  outline: none;
  font-family: inherit;
  transition: border-color 0.15s;
}

.chat-input:focus { border-color: var(--border-focus); }
.chat-input:disabled { opacity: 0.5; cursor: not-allowed; }
.chat-input::placeholder { color: var(--text-muted); }

/* ── Buttons ───────────────────────────────────────────────────────────────── */
.btn-primary {
  background: var(--accent);
  color: #fff;
  border: none;
  border-radius: var(--radius-sm);
  padding: 10px 18px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.15s, opacity 0.15s;
  white-space: nowrap;
}
.btn-primary:hover:not(:disabled) { background: #6d28d9; }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }

.btn-ghost {
  background: transparent;
  color: var(--text-secondary);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 8px 12px;
  font-size: 13px;
  cursor: pointer;
  width: 100%;
  text-align: left;
  transition: background 0.15s, color 0.15s;
}
.btn-ghost:hover { background: var(--bg-hover); color: var(--text-primary); }

/* ── Utility ───────────────────────────────────────────────────────────────── */
.muted { color: var(--text-muted); font-size: 13px; }
.error-text { color: var(--danger); font-size: 13px; }

.error-banner {
  background: #450a0a;
  border: 1px solid var(--danger);
  color: #fca5a5;
  font-size: 13px;
  padding: 10px 16px;
  margin: 0 20px 8px;
  border-radius: var(--radius-sm);
  cursor: pointer;
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.dismiss { opacity: 0.6; }

.send-btn { align-self: flex-end; }

examples/05-marketplace-chat/frontend/tsconfig.app.json
JSON
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

examples/05-marketplace-chat/frontend/tsconfig.json
JSON
{
  "files": [],
  "references": [
    { "path": "./tsconfig.app.json" }
  ]
}

examples/05-marketplace-chat/frontend/vite.config.ts
TypeScript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  base: '/',
  server: {
    port: 5175,
    // In development, proxy API calls to the FastAPI backend.
    // In production (Docker), the frontend is served by FastAPI directly —
    // all requests go to the same origin so no proxy is needed.
    proxy: {
      '/api': {
        target: 'http://localhost:8080',
        changeOrigin: true,
      },
    },
  },
  build: {
    outDir: 'dist',
    emptyOutDir: true,
  },
})

examples/05-marketplace-chat/main.py
Python
"""Gaia Chat — Cohesity Marketplace Edition.

Single-container app: FastAPI serves the React build as static files.
The Gaia API key is pre-configured by the admin via the appspec.yaml env block.
No login screen — authentication is handled by the Cohesity Marketplace token gate.
"""

from __future__ import annotations

import contextlib
from pathlib import Path

from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from fastapi.staticfiles import StaticFiles

from api.routes import router
from gaia_sdk.exceptions import GaiaAuthError, GaiaError, GaiaNotFoundError, GaiaRateLimitError
from settings import get_settings


@contextlib.asynccontextmanager
async def lifespan(_app: FastAPI):
    yield


def create_app() -> FastAPI:
    settings = get_settings()

    app = FastAPI(
        title="Gaia Chat — Marketplace App",
        version="1.0.0",
        lifespan=lifespan,
        # Keep API docs available at /api/docs for debugging
        docs_url="/api/docs",
        redoc_url=None,
    )

    # CORS is only needed during local development when the Vite dev server
    # runs on a different port than the FastAPI backend.
    # In production (Docker / Marketplace), the frontend is served from the
    # same origin, so CORS is not required.
    if settings.allow_cors_origin:
        app.add_middleware(
            CORSMiddleware,
            allow_origins=[settings.allow_cors_origin],
            allow_credentials=True,
            allow_methods=["*"],
            allow_headers=["*"],
        )

    @app.exception_handler(GaiaError)
    async def gaia_error_handler(request: Request, exc: GaiaError) -> JSONResponse:
        if isinstance(exc, GaiaAuthError):
            status_code = exc.status_code or 401
        elif isinstance(exc, GaiaNotFoundError):
            status_code = 404
        elif isinstance(exc, GaiaRateLimitError):
            status_code = 429
        else:
            status_code = 502
        return JSONResponse(status_code=status_code, content={"detail": str(exc)})

    app.include_router(router, prefix="/api/v1")

    # Serve the React build for all other routes (production / Docker).
    # In local development mode (Vite dev server), this directory won't exist
    # and the frontend is proxied to FastAPI via vite.config.ts.
    static_dir = Path(__file__).parent / "static"
    if static_dir.exists():
        app.mount("/", StaticFiles(directory=str(static_dir), html=True), name="static")

    return app


app = create_app()

examples/05-marketplace-chat/requirements.txt
Text Only
fastapi>=0.111
uvicorn[standard]>=0.30
pydantic>=2.0
pydantic-settings>=2.3
# gaia-sdk is a local package — run `make sdk-install` from the project root first
gaia-sdk

examples/05-marketplace-chat/settings.py
Python
"""Application settings — loaded from environment variables or .env file."""

from functools import lru_cache

from pydantic_settings import BaseSettings


class Settings(BaseSettings):
    # ── Gaia API ──────────────────────────────────────────────────────────
    # Set GAIA_API_KEY in the appspec.yaml env block when deploying to the
    # Cohesity Marketplace. The admin provides this value — it never touches
    # the user's browser.
    gaia_api_key: str
    gaia_base_url: str = "https://helios.cohesity.com/v2/mcm/gaia"
    gaia_verify_ssl: bool = True
    request_timeout_seconds: int = 60

    # ── Cohesity App SDK ──────────────────────────────────────────────────
    # These are automatically injected by the Cohesity Marketplace platform
    # when the app is running on a cluster. They are empty when running locally.
    host_ip: str = ""
    apps_api_endpoint_ip: str = ""
    apps_api_endpoint_port: str = ""
    app_authentication_token: str = ""

    # ── Development only ──────────────────────────────────────────────────
    # Set this when running the Vite dev server alongside the FastAPI backend
    # so the frontend (port 5175) can reach the backend (port 8080).
    # Leave empty in production — same-origin requests don't need CORS.
    allow_cors_origin: str = ""

    model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}

    @property
    def is_marketplace(self) -> bool:
        """True when running inside the Cohesity Marketplace platform."""
        return bool(self.app_authentication_token)


@lru_cache
def get_settings() -> Settings:
    return Settings()

examples/05-marketplace-chat/wrapper.sh
Bash
#!/bin/bash
# Cohesity Marketplace crash-recovery wrapper.
# The Marketplace platform expects apps to self-heal. If the server crashes,
# this script restarts it automatically rather than letting the pod exit.
# The platform will terminate the pod cleanly when the app is stopped by the user.

set -e

while true; do
    echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Starting Gaia Chat server on port 8080..."
    uvicorn main:app --host 0.0.0.0 --port 8080 --workers 1

    exit_code=$?
    if [ $exit_code -eq 0 ]; then
        echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Server exited cleanly."
        break
    fi

    echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Server crashed (exit code $exit_code). Restarting in 5 seconds..."
    sleep 5
done

Example 06 - Marketplace MCP Server

File Index

File Viewer

examples/06-marketplace-mcp/.env.example
Text Only
# Required: Gaia API key
# In Marketplace deployments, set this in appspec.yaml instead.
GAIA_API_KEY=your-gaia-api-key-here

# Optional overrides
GAIA_BASE_URL=https://helios.cohesity.com/v2/mcm/gaia
GAIA_VERIFY_SSL=true
GAIA_TIMEOUT_SECONDS=60

# Local development port (in Marketplace, PORT is controlled by appspec.yaml)
PORT=8002

examples/06-marketplace-mcp/Dockerfile
Docker
# Gaia MCP Server — Marketplace Edition
# Lightweight single-stage image — no frontend build needed.

FROM python:3.11-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY server.py wrapper.sh ./
RUN chmod +x wrapper.sh

# The Marketplace exposes this port via the NodePort service defined in appspec.yaml.
EXPOSE 8002

CMD ["./wrapper.sh"]

examples/06-marketplace-mcp/README.md
Markdown
# Example 06 — Marketplace MCP Server

An MCP (Model Context Protocol) server packaged for the **Cohesity App Marketplace**. Once deployed, this server exposes your Gaia knowledge bases as MCP tools that any MCP-compatible AI assistant — Cursor, Claude Desktop, Microsoft Copilot, and others — can call directly.

---

## What This Example Demonstrates

- **Marketplace-hosted MCP server** — Deploy once to a Cohesity cluster; every MCP client on your network can connect.
- **`unrestricted_app_ui_access: true`** — MCP clients connect programmatically (no browser), so the Cohesity token gate is bypassed. Auth is handled by the API key embedded in `appspec.yaml`.
- **`cohesityEnv: MCP_NODE_PORT`** — The Marketplace injects the dynamically assigned NodePort into the container so the server can display the correct connection URL.
- **FastMCP tools** — Gaia operations exposed as typed MCP tools: `list_datasets`, `ask`, `exhaustive_search`, `list_conversations`.
- **Synchronous httpx** — FastMCP tool functions are synchronous; the server uses a synchronous `httpx.Client` instead of the async `GaiaClient`.
- **HTML landing page** — Visiting the server URL in a browser shows the MCP connection URL and ready-to-paste Cursor/Claude Desktop configuration.

---

## Architecture

```
Your Network
┌──────────────────────────────────────────────────────────────┐
│                                                              │
│  MCP Client (Cursor / Claude Desktop / MS Copilot / ...)    │
│       │                                                      │
│       │  HTTP  POST /mcp  (MCP Streamable HTTP)             │
│       ▼                                                      │
│  Cohesity Cluster (port: dynamically assigned NodePort)      │
│  ┌────────────────────────────────────────────┐             │
│  │  gaia-mcp:latest                           │             │
│  │                                            │             │
│  │  FastMCP Server                            │──▶ Gaia API │
│  │   ├── /mcp          (MCP endpoint)         │   (Helios)  │
│  │   └── /             (Landing page)         │             │
│  └────────────────────────────────────────────┘             │
└──────────────────────────────────────────────────────────────┘
```

---

## MCP Tools Exposed

| Tool | Description |
|------|-------------|
| `list_datasets` | Lists all available Gaia datasets (knowledge bases) |
| `ask` | Sends a RAG query to one or more datasets; returns the answer and source citations |
| `exhaustive_search` | Runs exhaustive search across a dataset; returns a list of matching documents |
| `list_conversations` | Retrieves conversation history for multi-turn dialogue |

---

## File Structure

```
examples/06-marketplace-mcp/
├── server.py             # FastMCP server with 4 tools + HTML landing page
├── requirements.txt      # Python dependencies (fastapi, mcp, httpx)
├── .env.example          # Local development config
├── Dockerfile            # Single-stage Python image (no frontend)
├── wrapper.sh            # Crash-recovery restart loop
├── appspec.yaml          # Cohesity Marketplace deployment manifest
└── app.json              # app metadata (unrestricted_app_ui_access: true)
```

---

## Local Development

```bash
cd examples/06-marketplace-mcp

cp .env.example .env
# Edit .env and set GAIA_API_KEY

pip install -r requirements.txt
uvicorn server:app --host 0.0.0.0 --port 8002 --reload
```

- MCP endpoint: `http://localhost:8002/mcp`
- Landing page: `http://localhost:8002/`

---

## Testing Locally with MCP Inspector

```bash
npx @modelcontextprotocol/inspector http://localhost:8002/mcp
```

This opens a browser-based MCP client where you can call tools interactively and inspect requests and responses.

---

## Connecting from Cursor

Add to `~/.cursor/mcp.json` (replace with your actual host and port):

```json
{
  "mcpServers": {
    "gaia": {
      "url": "http://YOUR_HOST_IP:YOUR_NODE_PORT/mcp"
    }
  }
}
```

---

## Connecting from Claude Desktop

Add to `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "gaia": {
      "url": "http://YOUR_HOST_IP:YOUR_NODE_PORT/mcp",
      "transport": "http"
    }
  }
}
```

The landing page at `http://YOUR_HOST_IP:YOUR_NODE_PORT/` shows these snippets with the correct values pre-filled.

---

## Building the Docker Image

```bash
cd examples/06-marketplace-mcp

docker build -t gaia-mcp:latest .
docker run -p 8002:8002 \
  -e GAIA_API_KEY="your-api-key" \
  gaia-mcp:latest
```

---

## Deploying to the Cohesity Marketplace

### Prerequisites

- Cohesity cluster running version **7.2 or later** (Gaia required)
- Access to the Cohesity DevPortal
- A Gaia API key

### Steps

1. **Build and tag the image:**
   ```bash
   docker build -t gaia-mcp:latest .
   ```

2. **Update `appspec.yaml`** — replace `YOUR_GAIA_API_KEY` with your actual API key.

3. **Package the app:**
   ```bash
   docker save gaia-mcp:latest -o gaia-mcp.tar
   tar -czf gaia-mcp-app.tar.gz appspec.yaml app.json gaia-mcp.tar
   ```

4. **Validate the appspec:**
   ```bash
   ./appspecvalidator_exec appspec.yaml
   ```

5. **Upload to DevPortal** at your Cohesity cluster UI → Apps → Upload.

### After Deployment

- The Marketplace assigns a NodePort and injects it as `MCP_NODE_PORT` into the container.
- The landing page at `http://HOST_IP:NODE_PORT/` shows the correct MCP URL.
- MCP clients on your network connect to `http://HOST_IP:NODE_PORT/mcp`.
- No browser authentication is required (`unrestricted_app_ui_access: true`).

---

## Security Considerations

### Why `unrestricted_app_ui_access: true`?

MCP clients (Cursor, Claude Desktop, custom agents) connect programmatically without a browser session. They cannot complete a Cohesity SSO login flow. Setting `unrestricted_app_ui_access: true` in `app.json` bypasses the Marketplace token gate, allowing unauthenticated HTTP access to the MCP endpoint.

**The API key in `appspec.yaml` is the authentication mechanism.** Keep it secret — the Gaia API key controls what data the MCP tools can access.

### Network-Level Access Control

Since there is no token gate, access control relies on network isolation:
- The MCP server is accessible to anyone who can reach the NodePort on the cluster.
- Use cluster network policies or VPN to restrict which clients can reach the NodePort.
- Rotate the Gaia API key periodically using `appspec.yaml` updates and redeployment.

### Coming Soon

OAuth2 support for the Gaia API is planned. When released, the MCP server will support proper user-level auth delegation and the `unrestricted_app_ui_access` tradeoff will be re-evaluated.

---

## Environment Variables

| Variable | Source | Purpose |
|----------|--------|---------|
| `GAIA_API_KEY` | `appspec.yaml` env block | Required — authenticates with Gaia |
| `GAIA_BASE_URL` | `appspec.yaml` / `.env` | Override Helios URL |
| `GAIA_VERIFY_SSL` | `appspec.yaml` / `.env` | Set `false` for dev/self-signed |
| `GAIA_TIMEOUT_SECONDS` | `appspec.yaml` / `.env` | Request timeout (default 60) |
| `HOST_IP` | Auto-injected by Marketplace | Cluster node IP — shown on landing page |
| `MCP_NODE_PORT` | Auto-injected via `cohesityEnv` | Actual NodePort — shown on landing page |
| `PORT` | Fallback for local dev | Container listen port (default 8002) |

---

## Minimum Cluster Version

**Cohesity 7.2** — Gaia must be enabled on the cluster.

examples/06-marketplace-mcp/app.json
JSON
{
  "id": 2,
  "name": "Gaia MCP Server",
  "version": 1,
  "dev_version": 1.0,
  "description": "Expose Cohesity Gaia knowledge bases as MCP tools for AI assistants (Claude Desktop, Cursor, Microsoft Copilot, and more). Once deployed, any MCP-compatible AI tool can securely query your enterprise knowledge bases.",
  "access_requirements": {
    "read_access": false,
    "read_write_access": false,
    "management_access": false,
    "auto_mount_access": false,
    "unrestricted_app_ui_access": true
  }
}

examples/06-marketplace-mcp/appspec.yaml
YAML
## Gaia MCP Server — Cohesity Marketplace AppSpec
## Minimum cluster version: 7.2 (requires Gaia)
##
## unrestricted_app_ui_access: true (set in app.json) — MCP clients connect
## programmatically and don't have a Cohesity browser session to authenticate.
##
## Replace YOUR_GAIA_API_KEY before uploading to the DevPortal.
## Validate: ./appspecvalidator_exec appspec.yaml

apiVersion: v1
kind: Service
metadata:
  name: gaia-mcp-service
  labels:
    app: gaia-mcp
spec:
  type: NodePort
  selector:
    app: gaia-mcp
  ports:
    - port: 8002
      protocol: TCP
      name: mcp
      cohesityTag: ui         # Exposes this port in the Cohesity Marketplace UI.
                               # unrestricted_app_ui_access: true (app.json) means
                               # MCP clients don't need a Cohesity token to connect.
      cohesityEnv: MCP_NODE_PORT  # Injects the assigned NodePort number as
                                   # MCP_NODE_PORT into all pods, so the landing
                                   # page can display the correct connection URL.

---

apiVersion: apps/v1
kind: ReplicaSet
metadata:
  name: gaia-mcp
  labels:
    app: gaia-mcp
spec:
  replicas:
    fixed: 1
  selector:
    matchLabels:
      app: gaia-mcp
  template:
    metadata:
      labels:
        app: gaia-mcp
    spec:
      containers:
        - name: gaia-mcp
          image: gaia-mcp:latest
          resources:
            requests:
              cpu: 250m
              memory: 128Mi
          env:
            - name: GAIA_API_KEY
              value: "YOUR_GAIA_API_KEY"          # ← replace this
            - name: GAIA_BASE_URL
              value: "https://helios.cohesity.com/v2/mcm/gaia"
            - name: GAIA_VERIFY_SSL
              value: "true"
            - name: GAIA_TIMEOUT_SECONDS
              value: "60"

## ── How MCP_NODE_PORT works ───────────────────────────────────────────────────
##
## The cohesityEnv: MCP_NODE_PORT tag tells the platform to inject the actual
## assigned NodePort number into the container at runtime. The server reads this
## value and displays it in the landing page so users know exactly which URL
## to put in their Cursor / Claude Desktop MCP config.
##
## Example landing page URL: http://192.168.1.10:31234/mcp
## where 192.168.1.10 is HOST_IP and 31234 is MCP_NODE_PORT.

examples/06-marketplace-mcp/requirements.txt
Text Only
fastapi>=0.111
uvicorn[standard]>=0.30
httpx>=0.27
python-dotenv>=1.0
mcp>=1.0

examples/06-marketplace-mcp/server.py
Python
"""Gaia MCP Server — Cohesity Marketplace Edition.

Exposes Cohesity Gaia knowledge bases as MCP tools so AI assistants
(Claude Desktop, Cursor, Microsoft Copilot, etc.) can query enterprise data
securely through the Cohesity Marketplace.

When deployed on the Cohesity Marketplace:
  - The GAIA_API_KEY is pre-configured in appspec.yaml (admin sets this)
  - The MCP endpoint is accessible at http://{HOST_IP}:{MCP_NODE_PORT}/mcp
  - MCP_NODE_PORT is auto-injected via the cohesityEnv tag in appspec.yaml
  - unrestricted_app_ui_access: true in app.json — MCP clients connect
    programmatically and don't have a Cohesity browser session

Note: FastMCP tools are synchronous functions. We use httpx.Client (sync)
rather than gaia_sdk's async GaiaClient to avoid asyncio.run() complications.
"""

from __future__ import annotations

import contextlib
import os
from typing import Any

import httpx
from dotenv import load_dotenv
from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from mcp.server.fastmcp import FastMCP

load_dotenv()


# ── Gaia API helpers ──────────────────────────────────────────────────────────

def _required_env(name: str) -> str:
    value = os.getenv(name, "").strip()
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value


def _gaia_base_url() -> str:
    return os.getenv("GAIA_BASE_URL", "https://helios.cohesity.com/v2/mcm/gaia").rstrip("/")


def _gaia_headers() -> dict[str, str]:
    return {
        "apiKey": _required_env("GAIA_API_KEY"),
        "Content-Type": "application/json",
        "Accept": "application/json",
    }


def _gaia_timeout() -> float:
    try:
        return float(os.getenv("GAIA_TIMEOUT_SECONDS", "60"))
    except ValueError:
        return 60.0


def _gaia_verify_ssl() -> bool:
    return os.getenv("GAIA_VERIFY_SSL", "true").lower() in {"1", "true", "yes", "on"}


def _gaia_request(
    method: str,
    path: str,
    *,
    params: dict[str, Any] | None = None,
    json: dict[str, Any] | None = None,
) -> dict[str, Any]:
    url = f"{_gaia_base_url()}{path}"
    with httpx.Client(timeout=_gaia_timeout(), verify=_gaia_verify_ssl()) as client:
        response = client.request(method, url, headers=_gaia_headers(), params=params, json=json)

    if response.status_code >= 400:
        try:
            payload = response.json()
            message = payload.get("message") or payload.get("error") or response.text
        except Exception:
            message = response.text
        raise RuntimeError(f"Gaia API {response.status_code}: {message}")

    return response.json()


# ── MCP tools ─────────────────────────────────────────────────────────────────

mcp = FastMCP(
    "Gaia MCP Server",
    json_response=True,
    streamable_http_path="/",
    host="0.0.0.0",
)


@mcp.tool()
def list_datasets(search_term: str | None = None) -> dict[str, Any]:
    """List datasets available in Cohesity Gaia.

    Returns dataset names, statuses, and object counts. Use the name field
    when calling ask() or exhaustive_search().
    """
    params: dict[str, Any] = {}
    if search_term:
        params["datasetsSearchTerm"] = search_term
    return _gaia_request("GET", "/datasets", params=params)


@mcp.tool()
def ask(
    question: str,
    dataset_names: list[str],
    conversation_id: str | None = None,
    llm_name: str | None = None,
) -> dict[str, Any]:
    """Ask a RAG question against one or more Gaia datasets.

    Returns a natural-language answer grounded in documents from the specified
    datasets, along with source documents and a query_uid for follow-up calls.

    Pass conversation_id from a previous response to maintain multi-turn context
    (Gaia retains the last 3 Q&A pairs per conversation).
    """
    body: dict[str, Any] = {
        "queryString": question,
        "datasetNames": dataset_names,
    }
    if conversation_id:
        body["conversationId"] = conversation_id
    if llm_name:
        body["llmName"] = llm_name
    return _gaia_request("POST", "/ask", json=body)


@mcp.tool()
def exhaustive_search(
    query_string: str,
    dataset_name: str,
    page_size: int = 20,
    pagination_token: str | None = None,
) -> dict[str, Any]:
    """Run an exhaustive document search — returns all matching documents, not a summary.

    Ideal for e-discovery, compliance, and bulk retrieval. Use pagination_token
    from a previous response to retrieve the next page of results.
    """
    body: dict[str, Any] = {
        "queryString": query_string,
        "datasetName": dataset_name,
        "pageSize": page_size,
    }
    if pagination_token:
        body["paginationToken"] = pagination_token
    return _gaia_request("PUT", "/ask/exhaustive", json=body)


@mcp.tool()
def list_conversations() -> dict[str, Any]:
    """List recent Gaia conversations (up to 60 days old)."""
    return _gaia_request("GET", "/conversations")


# ── FastAPI app ───────────────────────────────────────────────────────────────

@contextlib.asynccontextmanager
async def lifespan(_: FastAPI):
    async with mcp.session_manager.run():
        yield


app = FastAPI(
    title="Gaia MCP Server — Marketplace Edition",
    version="1.0.0",
    lifespan=lifespan,
)

app.mount("/mcp", mcp.streamable_http_app())


@app.get("/healthz", tags=["System"])
def healthz() -> dict[str, str]:
    """Health check endpoint."""
    return {"status": "ok"}


@app.get("/", response_class=HTMLResponse, tags=["System"])
def root() -> str:
    """Landing page — shows the MCP connection URL for the current environment."""
    host_ip = os.getenv("HOST_IP", "localhost")
    # MCP_NODE_PORT is injected by Cohesity via cohesityEnv in appspec.yaml
    node_port = os.getenv("MCP_NODE_PORT", os.getenv("PORT", "8002"))
    is_marketplace = bool(os.getenv("APP_AUTHENTICATION_TOKEN"))

    mcp_url = f"http://{host_ip}:{node_port}/mcp"

    cursor_config = f"""{{
  "mcpServers": {{
    "gaia": {{
      "url": "{mcp_url}"
    }}
  }}
}}"""

    claude_config = f"""{{
  "mcpServers": {{
    "gaia": {{
      "type": "http",
      "url": "{mcp_url}"
    }}
  }}
}}"""

    return f"""<!doctype html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Gaia MCP Server</title>
  <style>
    :root {{
      --bg: #0f1117; --surface: #1a1f2e; --elevated: #242938;
      --border: #2d3448; --text: #e2e8f0; --muted: #94a3b8;
      --accent: #a78bfa; --teal: #2dd4bf; --radius: 10px;
    }}
    * {{ box-sizing: border-box; margin: 0; padding: 0; }}
    body {{
      font-family: -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
      background: var(--bg); color: var(--text);
      max-width: 820px; margin: 0 auto; padding: 48px 24px;
      line-height: 1.6; font-size: 15px;
    }}
    h1 {{ color: var(--accent); font-size: 28px; margin-bottom: 6px; }}
    h2 {{ font-size: 16px; color: var(--muted); font-weight: 500; margin-bottom: 24px; }}
    h3 {{ font-size: 14px; font-weight: 600; color: var(--muted);
         text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 10px; }}
    .card {{
      background: var(--surface); border: 1px solid var(--border);
      border-radius: var(--radius); padding: 20px; margin-bottom: 20px;
    }}
    .url-box {{
      background: var(--elevated); border: 1px solid var(--border);
      border-radius: 6px; padding: 12px 16px; font-family: monospace;
      font-size: 14px; color: var(--teal); word-break: break-all;
      margin-bottom: 8px;
    }}
    .note {{ font-size: 13px; color: var(--muted); margin-top: 8px; }}
    .tools-grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }}
    .tool {{
      background: var(--elevated); border: 1px solid var(--border);
      border-radius: 8px; padding: 14px;
    }}
    .tool-name {{ color: var(--accent); font-weight: 600; font-size: 14px; margin-bottom: 4px; }}
    .tool-desc {{ font-size: 13px; color: var(--muted); }}
    pre {{
      background: var(--elevated); border: 1px solid var(--border);
      border-radius: 6px; padding: 14px; font-size: 13px; overflow-x: auto;
      color: var(--teal); font-family: monospace; margin-top: 8px;
    }}
    .badge {{
      display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
      border-radius: 12px; background: #553c9a; color: #e9d8fd;
      text-transform: uppercase; letter-spacing: 0.4px; margin-left: 10px;
    }}
  </style>
</head>
<body>
  <h1>Gaia MCP Server {'<span class="badge">Marketplace</span>' if is_marketplace else ''}</h1>
  <h2>Cohesity Gaia knowledge bases — exposed as MCP tools</h2>

  <div class="card">
    <h3>MCP Endpoint</h3>
    <div class="url-box">{mcp_url}</div>
    <p class="note">Add this URL to your MCP client configuration below.</p>
  </div>

  <div class="card">
    <h3>Available Tools</h3>
    <div class="tools-grid">
      <div class="tool">
        <div class="tool-name">list_datasets</div>
        <div class="tool-desc">List available Gaia datasets with status and counts</div>
      </div>
      <div class="tool">
        <div class="tool-name">ask</div>
        <div class="tool-desc">RAG query — natural-language answer grounded in your data</div>
      </div>
      <div class="tool">
        <div class="tool-name">exhaustive_search</div>
        <div class="tool-desc">Full document retrieval with pagination (e-discovery)</div>
      </div>
      <div class="tool">
        <div class="tool-name">list_conversations</div>
        <div class="tool-desc">List recent multi-turn conversations</div>
      </div>
    </div>
  </div>

  <div class="card">
    <h3>Cursor — .cursor/mcp.json</h3>
    <pre>{cursor_config}</pre>
  </div>

  <div class="card">
    <h3>Claude Desktop — claude_desktop_config.json</h3>
    <pre>{claude_config}</pre>
  </div>

  <div class="card">
    <h3>Health</h3>
    <p><a href="/healthz" style="color:var(--teal)">/healthz</a> — returns <code style="color:var(--teal)">{{"status":"ok"}}</code></p>
  </div>
</body>
</html>"""

examples/06-marketplace-mcp/wrapper.sh
Bash
#!/bin/bash
# Cohesity Marketplace crash-recovery wrapper.
# Automatically restarts the MCP server on crash.

set -e

while true; do
    echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Starting Gaia MCP Server on port 8002..."
    uvicorn server:app --host 0.0.0.0 --port 8002 --workers 1

    exit_code=$?
    if [ $exit_code -eq 0 ]; then
        echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Server exited cleanly."
        break
    fi

    echo "[$(date -u '+%Y-%m-%dT%H:%M:%SZ')] Server crashed (exit code $exit_code). Restarting in 5 seconds..."
    sleep 5
done