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¶
examples/01-hello-gaia/.env.exampleexamples/01-hello-gaia/README.mdexamples/01-hello-gaia/hello_gaia.pyexamples/01-hello-gaia/requirements.txt
File Viewer¶
examples/01-hello-gaia/.env.example
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())
Example 02 - Chat App¶
File Index¶
examples/02-chat-app/README.mdexamples/02-chat-app/backend/.env.exampleexamples/02-chat-app/backend/api/__init__.pyexamples/02-chat-app/backend/api/dependencies.pyexamples/02-chat-app/backend/api/routes.pyexamples/02-chat-app/backend/main.pyexamples/02-chat-app/backend/models/__init__.pyexamples/02-chat-app/backend/models/api_models.pyexamples/02-chat-app/backend/requirements.txtexamples/02-chat-app/backend/services/__init__.pyexamples/02-chat-app/backend/services/session_service.pyexamples/02-chat-app/backend/settings.pyexamples/02-chat-app/backend/utils/__init__.pyexamples/02-chat-app/backend/utils/errors.pyexamples/02-chat-app/frontend/.env.exampleexamples/02-chat-app/frontend/index.htmlexamples/02-chat-app/frontend/package.jsonexamples/02-chat-app/frontend/src/App.tsxexamples/02-chat-app/frontend/src/api/client.tsexamples/02-chat-app/frontend/src/api/gaia.tsexamples/02-chat-app/frontend/src/components/ChatInput.tsxexamples/02-chat-app/frontend/src/components/ChatMessage.tsxexamples/02-chat-app/frontend/src/components/DatasetSelector.tsxexamples/02-chat-app/frontend/src/main.tsxexamples/02-chat-app/frontend/src/pages/ChatPage.tsxexamples/02-chat-app/frontend/src/pages/LoginPage.tsxexamples/02-chat-app/frontend/src/state/chatStore.tsexamples/02-chat-app/frontend/src/state/sessionStore.tsexamples/02-chat-app/frontend/src/styles/app.cssexamples/02-chat-app/frontend/tsconfig.jsonexamples/02-chat-app/frontend/vite.config.ts
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/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/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
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/errors.py
examples/02-chat-app/frontend/index.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
Example 03 - Document Search¶
File Index¶
examples/03-document-search/README.mdexamples/03-document-search/backend/.env.exampleexamples/03-document-search/backend/api/__init__.pyexamples/03-document-search/backend/api/dependencies.pyexamples/03-document-search/backend/api/routes.pyexamples/03-document-search/backend/main.pyexamples/03-document-search/backend/models/__init__.pyexamples/03-document-search/backend/models/api_models.pyexamples/03-document-search/backend/requirements.txtexamples/03-document-search/backend/services/__init__.pyexamples/03-document-search/backend/services/search_service.pyexamples/03-document-search/backend/services/session_service.pyexamples/03-document-search/backend/settings.pyexamples/03-document-search/backend/utils/__init__.pyexamples/03-document-search/backend/utils/errors.pyexamples/03-document-search/frontend/.env.exampleexamples/03-document-search/frontend/index.htmlexamples/03-document-search/frontend/package.jsonexamples/03-document-search/frontend/src/App.tsxexamples/03-document-search/frontend/src/api/client.tsexamples/03-document-search/frontend/src/api/gaia.tsexamples/03-document-search/frontend/src/components/DocumentCard.tsxexamples/03-document-search/frontend/src/components/DocumentDetail.tsxexamples/03-document-search/frontend/src/components/FeedbackButtons.tsxexamples/03-document-search/frontend/src/components/RefinePanel.tsxexamples/03-document-search/frontend/src/components/SearchBar.tsxexamples/03-document-search/frontend/src/main.tsxexamples/03-document-search/frontend/src/pages/DatasetBrowserPage.tsxexamples/03-document-search/frontend/src/pages/LoginPage.tsxexamples/03-document-search/frontend/src/pages/SearchPage.tsxexamples/03-document-search/frontend/src/state/searchStore.tsexamples/03-document-search/frontend/src/state/sessionStore.tsexamples/03-document-search/frontend/src/styles/app.cssexamples/03-document-search/frontend/tsconfig.jsonexamples/03-document-search/frontend/vite.config.ts
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/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/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
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/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
examples/03-document-search/frontend/index.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"
>
👍
</button>
<button
className="feedback-btn bad"
onClick={() => handleFeedback(false)}
title="Bad result"
>
👎
</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
Example 04 - Gaia MCP Server¶
File Index¶
examples/04-gaia-mcp-server/.env.exampleexamples/04-gaia-mcp-server/Dockerfileexamples/04-gaia-mcp-server/README.mdexamples/04-gaia-mcp-server/requirements.txtexamples/04-gaia-mcp-server/server.py
File Viewer¶
examples/04-gaia-mcp-server/.env.example
examples/04-gaia-mcp-server/Dockerfile
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
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¶
examples/05-marketplace-chat/.env.exampleexamples/05-marketplace-chat/Dockerfileexamples/05-marketplace-chat/README.mdexamples/05-marketplace-chat/api/__init__.pyexamples/05-marketplace-chat/api/models.pyexamples/05-marketplace-chat/api/routes.pyexamples/05-marketplace-chat/app.jsonexamples/05-marketplace-chat/appspec.yamlexamples/05-marketplace-chat/frontend/index.htmlexamples/05-marketplace-chat/frontend/package.jsonexamples/05-marketplace-chat/frontend/src/App.tsxexamples/05-marketplace-chat/frontend/src/api/client.tsexamples/05-marketplace-chat/frontend/src/api/gaia.tsexamples/05-marketplace-chat/frontend/src/components/ChatInput.tsxexamples/05-marketplace-chat/frontend/src/components/ChatMessage.tsxexamples/05-marketplace-chat/frontend/src/components/DatasetSelector.tsxexamples/05-marketplace-chat/frontend/src/main.tsxexamples/05-marketplace-chat/frontend/src/pages/ChatPage.tsxexamples/05-marketplace-chat/frontend/src/state/chatStore.tsexamples/05-marketplace-chat/frontend/src/styles/app.cssexamples/05-marketplace-chat/frontend/tsconfig.app.jsonexamples/05-marketplace-chat/frontend/tsconfig.jsonexamples/05-marketplace-chat/frontend/vite.config.tsexamples/05-marketplace-chat/main.pyexamples/05-marketplace-chat/requirements.txtexamples/05-marketplace-chat/settings.pyexamples/05-marketplace-chat/wrapper.sh
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/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
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
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
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
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
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¶
examples/06-marketplace-mcp/.env.exampleexamples/06-marketplace-mcp/Dockerfileexamples/06-marketplace-mcp/README.mdexamples/06-marketplace-mcp/app.jsonexamples/06-marketplace-mcp/appspec.yamlexamples/06-marketplace-mcp/requirements.txtexamples/06-marketplace-mcp/server.pyexamples/06-marketplace-mcp/wrapper.sh
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
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