Skip to main content
Source Code: src/gaia/ui/
Import:
from gaia.ui.server import create_app, DEFAULT_PORT
from gaia.ui.database import ChatDatabase, DEFAULT_DB_PATH
from gaia.ui.models import SystemStatus, ChatRequest, SessionResponse, DocumentResponse
See also: User Guide | Agent SDK | API Specification
Tested Configuration: The Agent UI has been tested on AMD Ryzen AI MAX+ 395 with Qwen3.5-35B-A3B-GGUF. Other configurations are not officially verified. See the User Guide for full details and how to report issues on other hardware.

Overview

The Agent UI SDK is the Python backend that powers the GAIA Agent UI. It provides:
  • FastAPI REST server with session, chat, and document endpoints
  • SQLite database for persistent sessions, messages, and document metadata
  • SSE streaming for real-time chat responses
  • RAG integration for document Q&A
  • Pydantic models for request/response validation
The backend runs on port 4200 by default and serves both the Electron desktop app and browser-based clients.

Quick Start

Start the Server

from gaia.ui.server import create_app

# Create with default database (~/.gaia/chat/gaia_chat.db)
app = create_app()

# Create with custom database path
app = create_app(db_path="/path/to/my/chat.db")

# Create with in-memory database (for testing)
app = create_app(db_path=":memory:")
Run with uvicorn:
import uvicorn
from gaia.ui.server import create_app

app = create_app()
uvicorn.run(app, host="localhost", port=4200)
Or from the command line:
python -m gaia.ui.server --port 4200

Use the Database Directly

from gaia.ui.database import ChatDatabase

db = ChatDatabase()  # Uses default path ~/.gaia/chat/gaia_chat.db

# Create a session
session = db.create_session(title="My Chat", model="Qwen3.5-35B-A3B-GGUF")
print(f"Session ID: {session['id']}")

# Add messages
db.add_message(session["id"], "user", "Hello!")
db.add_message(session["id"], "assistant", "Hi there! How can I help?")

# Retrieve messages
messages = db.get_messages(session["id"])
for msg in messages:
    print(f"{msg['role']}: {msg['content']}")

# Clean up
db.close()

Core Classes

ChatDatabase

The persistence layer for all Agent UI data. Uses SQLite with WAL mode for concurrent read access.
from gaia.ui.database import ChatDatabase, DEFAULT_DB_PATH
Constructor:
class ChatDatabase:
    def __init__(self, db_path: str = None):
        """Initialize database connection.

        Args:
            db_path: Path to SQLite database file.
                     Defaults to ~/.gaia/chat/gaia_chat.db.
                     Use ":memory:" for in-memory database (testing).
        """

Session Methods

MethodSignatureDescription
create_session(title?, model?, system_prompt?, document_ids?) -> DictCreate a new chat session
get_session(session_id) -> Optional[Dict]Get session by ID with message count and document IDs
list_sessions(limit=50, offset=0) -> List[Dict]List sessions ordered by most recently updated
count_sessions() -> intCount total sessions
update_session(session_id, title?, system_prompt?) -> Optional[Dict]Update session title and/or system prompt
delete_session(session_id) -> boolDelete a session and its messages (cascading)
touch_session(session_id) -> NoneUpdate the session’s updated_at timestamp
Example:
db = ChatDatabase()

# Create session with attached documents
session = db.create_session(
    title="Project Review",
    model="Qwen3.5-35B-A3B-GGUF",
    system_prompt="You are a code review assistant.",
    document_ids=["doc-abc123"],
)

# Update title
db.update_session(session["id"], title="Sprint 42 Review")

# List recent sessions
for s in db.list_sessions(limit=10):
    print(f"{s['title']} ({s['message_count']} messages)")

Message Methods

MethodSignatureDescription
add_message(session_id, role, content, rag_sources?, tokens_prompt?, tokens_completion?) -> intAdd a message, returns message ID
get_messages(session_id, limit=100, offset=0) -> List[Dict]Get messages oldest-first
count_messages(session_id) -> intCount messages in a session
Example:
# Add a user message
db.add_message(session_id, "user", "What does this function do?")

# Add an assistant message with RAG sources
db.add_message(
    session_id,
    "assistant",
    "This function initializes the database connection...",
    rag_sources=[
        {"document_id": "doc-123", "filename": "main.py", "chunk": "def init_db()...", "score": 0.92}
    ],
    tokens_prompt=150,
    tokens_completion=87,
)

# Retrieve conversation
messages = db.get_messages(session_id)
for msg in messages:
    print(f"[{msg['role']}] {msg['content'][:80]}...")

Document Methods

MethodSignatureDescription
add_document(filename, filepath, file_hash, file_size?, chunk_count?) -> DictAdd document to library (deduplicates by hash)
get_document(doc_id) -> Optional[Dict]Get document by ID
list_documents() -> List[Dict]List all documents
delete_document(doc_id) -> boolDelete a document
attach_document(session_id, document_id) -> boolAttach document to session
detach_document(session_id, document_id) -> boolDetach document from session
get_session_documents(session_id) -> List[Dict]Get all documents for a session
Example:
import hashlib

# Add a document
doc = db.add_document(
    filename="manual.pdf",
    filepath="/home/user/docs/manual.pdf",
    file_hash=hashlib.sha256(open("manual.pdf", "rb").read()).hexdigest(),
    file_size=1_234_567,
    chunk_count=45,
)

# Attach to a session
db.attach_document(session_id, doc["id"])

# Check which documents are in a session
docs = db.get_session_documents(session_id)
for d in docs:
    print(f"{d['filename']} ({d['chunk_count']} chunks)")

Statistics

stats = db.get_stats()
print(f"Sessions: {stats['sessions']}")
print(f"Messages: {stats['messages']}")
print(f"Documents: {stats['documents']}")
print(f"Total chunks: {stats['total_chunks']}")
print(f"Total size: {stats['total_size_bytes']} bytes")

create_app()

Factory function that creates and configures the FastAPI application with all endpoints.
from gaia.ui.server import create_app

def create_app(db_path: str = None) -> FastAPI:
    """Create and configure the FastAPI application.

    Args:
        db_path: Path to SQLite database.
                 None for default (~/.gaia/chat/gaia_chat.db).
                 ":memory:" for testing.

    Returns:
        Configured FastAPI application with all endpoints registered.
    """
The database instance is stored on app.state.db and is accessible in tests:
app = create_app(db_path=":memory:")
db = app.state.db

Pydantic Models

All request and response bodies use Pydantic models from gaia.ui.models.

System

class SystemStatus(BaseModel):
    """System readiness status returned by GET /api/system/status."""
    lemonade_running: bool = False
    model_loaded: Optional[str] = None
    embedding_model_loaded: bool = False
    disk_space_gb: float = 0.0
    memory_available_gb: float = 0.0
    initialized: bool = False
    version: str = "0.1.0"

Sessions

class CreateSessionRequest(BaseModel):
    """POST /api/sessions"""
    title: Optional[str] = None
    model: Optional[str] = None
    system_prompt: Optional[str] = None
    document_ids: List[str] = []

class UpdateSessionRequest(BaseModel):
    """PUT /api/sessions/{session_id}"""
    title: Optional[str] = None
    system_prompt: Optional[str] = None

class SessionResponse(BaseModel):
    """Returned by session endpoints."""
    id: str
    title: str
    created_at: str
    updated_at: str
    model: str
    system_prompt: Optional[str] = None
    message_count: int = 0
    document_ids: List[str] = []

class SessionListResponse(BaseModel):
    sessions: List[SessionResponse]
    total: int

Chat

class ChatRequest(BaseModel):
    """POST /api/chat/send"""
    session_id: str
    message: str
    document_ids: Optional[List[str]] = None
    stream: bool = True   # SSE streaming by default

class ChatResponse(BaseModel):
    """Non-streaming response from POST /api/chat/send."""
    message_id: int
    content: str
    sources: List[SourceInfo] = []
    tokens: Optional[Dict[str, int]] = None

class SourceInfo(BaseModel):
    """RAG source citation."""
    document_id: str
    filename: str
    chunk: str
    score: float
    page: Optional[int] = None

Messages

class MessageResponse(BaseModel):
    """Individual message in a session."""
    id: int
    session_id: str
    role: str             # "user", "assistant", or "system"
    content: str
    created_at: str
    rag_sources: Optional[List[SourceInfo]] = None
    agent_steps: Optional[List[AgentStepResponse]] = None

class MessageListResponse(BaseModel):
    messages: List[MessageResponse]
    total: int

Documents

class DocumentResponse(BaseModel):
    """Document in the global library."""
    id: str
    filename: str
    filepath: str
    file_size: int
    chunk_count: int
    indexed_at: str
    last_accessed_at: Optional[str] = None
    sessions_using: int = 0
    indexing_status: str = "complete"  # pending | indexing | complete | failed | cancelled | missing

class DocumentListResponse(BaseModel):
    documents: List[DocumentResponse]
    total: int
    total_size_bytes: int
    total_chunks: int

class DocumentUploadRequest(BaseModel):
    """POST /api/documents/upload-path"""
    filepath: str

class AttachDocumentRequest(BaseModel):
    """POST /api/sessions/{session_id}/documents"""
    document_id: str

REST API Endpoints

System

Check system readiness for the agent UI.Response:
{
  "lemonade_running": true,
  "model_loaded": "Qwen3.5-35B-A3B-GGUF",
  "embedding_model_loaded": false,
  "disk_space_gb": 128.5,
  "memory_available_gb": 12.3,
  "initialized": true,
  "version": "0.1.0"
}
Health check with database statistics.Response:
{
  "status": "ok",
  "service": "gaia-agent-ui",
  "stats": {
    "sessions": 12,
    "messages": 245,
    "documents": 5,
    "total_chunks": 320,
    "total_size_bytes": 15234567
  }
}
Trigger loading a model on the Lemonade server. Returns 202 immediately; loading proceeds in the background. Poll GET /api/system/status to detect when loading completes.Request:
{
  "model_name": "Qwen3.5-35B-A3B-GGUF",
  "ctx_size": 32768
}
FieldTypeRequiredDescription
model_namestringYesName of the model to load.
ctx_sizenumberNoContext window size in tokens. Defaults to 32768.
Response (202):
{
  "status": "loading",
  "model": "Qwen3.5-35B-A3B-GGUF",
  "ctx_size": 32768
}
Trigger downloading a model via the Lemonade server. Returns 202 immediately; the download proceeds in the background. Poll GET /api/system/status to detect when the model becomes available. Set force to true to re-download even if the file already exists (repairs corrupted or incomplete downloads).Request:
{
  "model_name": "Qwen3.5-35B-A3B-GGUF",
  "force": false
}
FieldTypeRequiredDescription
model_namestringYesName of the model to download.
forcebooleanNoRe-download even if the model already exists. Defaults to false.
Response (202):
{
  "status": "downloading",
  "model": "Qwen3.5-35B-A3B-GGUF"
}
Get current user settings including the custom model override and its status on the Lemonade server.Response:
{
  "custom_model": "huihui-ai/Huihui-Qwen3.5-35B-A3B-abliterated",
  "model_status": {
    "found": true,
    "downloaded": true,
    "loaded": false
  }
}
FieldTypeDescription
custom_modelstring or nullHuggingFace model ID overriding the default, or null if using the default model.
model_statusobject or nullStatus of the custom model on Lemonade. null when no custom model is set. Contains found (in catalog), downloaded (on disk), and loaded (currently active).
Update user settings. Set custom_model to a model ID to override the default, or to an empty string / null to clear the override and revert to the default model.Request:
{
  "custom_model": "huihui-ai/Huihui-Qwen3.5-35B-A3B-abliterated"
}
FieldTypeRequiredDescription
custom_modelstring or nullNoHuggingFace model ID to use instead of the default. Empty string or null clears the override.
Response: Same shape as GET /api/settings.

Sessions

Create a new chat session.Request:
{
  "title": "Code Review",
  "model": "Qwen3.5-35B-A3B-GGUF",
  "system_prompt": "You are a code reviewer.",
  "document_ids": ["doc-abc123"]
}
Response: SessionResponse
List all sessions, ordered by most recently updated.Query params: limit (default 50), offset (default 0)Response: SessionListResponse
Get session details including message count and attached document IDs.Response: SessionResponse
Update session title or system prompt.Request:
{
  "title": "Sprint 42 Review"
}
Delete a session and all its messages (cascading delete).
Get messages for a session, ordered oldest first.Query params: limit (default 100), offset (default 0)Response: MessageListResponse
Export a session to Markdown or JSON.Query params: format (“markdown” or “json”, default “markdown”)

Chat

Send a message and receive a response. Supports both streaming (SSE) and non-streaming modes.Request:
{
  "session_id": "abc-123",
  "message": "What does this code do?",
  "document_ids": ["doc-456"],
  "stream": true
}
Streaming response (SSE events):When stream: true, the server returns a text/event-stream response. Each line follows the SSE format data: <JSON>. The SSEOutputHandler (src/gaia/ui/sse_handler.py) bridges agent console events to the following typed events:Thinking and Progress
Event typeFieldsDescription
thinkingcontent (string)Agent reasoning or progress message. Emitted when the agent starts processing, thinks through a problem, or begins a long-running operation.
stepstep (int), total (int), status (string)Agent step progress. step is the current step number, total is the step limit, and status is "started".
statusstatus (string), message (string)General status update. status is one of "working", "complete", "warning", or "info". May also include steps (int) and elapsed (number) when status is "complete".
plansteps (string[]), current_step (int or null)Agent execution plan. Each entry in steps is a human-readable description of a planned action.
Tool Execution
Event typeFieldsDescription
tool_starttool (string), detail (string)Tool invocation started. tool is the tool function name (e.g., "query_documents", "search_file"). detail is a human-readable description of the operation.
tool_argstool (string), args (object), detail (string)Tool arguments. args is the raw arguments dict passed to the tool. detail is a formatted human-readable summary of the arguments.
tool_endsuccess (boolean)Tool invocation completed.
tool_resulttitle (string or null), summary (string), success (boolean), result_data (object or null), command_output (object or null)Tool result with structured data. summary is a human-readable result. result_data contains typed results (see below). command_output contains shell command output (see below).
result_data variants in tool_result:
  • File list: {"type": "file_list", "files": [...], "total": int} — up to 20 file entries
  • Search results: {"type": "search_results", "count": int, "scores": float[], "previews": string[]} — top 5 chunk previews (200 chars each)
command_output shape in tool_result:
{
  "command": "ls -la",
  "stdout": "total 42\n...",
  "stderr": "",
  "return_code": 0,
  "cwd": "/home/user/project",
  "duration_seconds": 0.15,
  "truncated": false
}
Response Content
Event typeFieldsDescription
chunkcontent (string)Incremental text fragment of the response, streamed as the LLM generates tokens. Raw tool-call JSON is automatically filtered out.
answercontent (string), elapsed (number), steps (int), tools_used (int)Final complete answer from the agent. elapsed is wall-clock seconds. steps and tools_used are execution totals. Double-escaped newlines/tabs from LLM output are automatically corrected.
agent_errorcontent (string)Error message from the agent.
Stream Termination
Event typeFieldsDescription
donemessage_id (int), content (string)Signals the end of the stream. message_id is the database ID of the saved assistant message. content is the full response text.
Example stream showing a typical multi-step interaction:
data: {"type": "thinking", "content": "Sending to Qwen3.5-35B..."}
data: {"type": "step", "step": 1, "total": 10, "status": "started"}
data: {"type": "tool_start", "tool": "query_documents", "detail": "Searching indexed documents for relevant content"}
data: {"type": "tool_args", "tool": "query_documents", "args": {"query": "database init"}, "detail": "query: database init"}
data: {"type": "tool_result", "title": "Result", "summary": "Found 3 relevant chunk(s) (best score: 0.87)", "success": true, "result_data": {"type": "search_results", "count": 3, "scores": [0.87, 0.72, 0.65], "previews": ["def init_db()..."]}}
data: {"type": "tool_end", "success": true}
data: {"type": "chunk", "content": "This function"}
data: {"type": "chunk", "content": " initializes"}
data: {"type": "chunk", "content": " the database..."}
data: {"type": "answer", "content": "This function initializes the database...", "elapsed": 3.45, "steps": 1, "tools_used": 1}
data: {"type": "status", "status": "complete", "message": "Completed in 1 steps", "steps": 1, "elapsed": 3.45}
data: {"type": "done", "message_id": 42, "content": "This function initializes the database..."}
Non-streaming response:
{
  "message_id": 42,
  "content": "This function initializes the database...",
  "sources": [],
  "tokens": null
}

Documents

List all documents in the global library.Response:
{
  "documents": [
    {
      "id": "doc-abc123",
      "filename": "manual.pdf",
      "filepath": "/home/user/docs/manual.pdf",
      "file_size": 1234567,
      "chunk_count": 45,
      "indexed_at": "2026-03-05T10:00:00Z",
      "last_accessed_at": "2026-03-05T14:30:00Z",
      "sessions_using": 3
    }
  ],
  "total": 1,
  "total_size_bytes": 1234567,
  "total_chunks": 45
}
Index a document by file path. The file is hashed for deduplication — if the same file was already indexed, the existing document is returned.Request:
{
  "filepath": "/home/user/docs/report.pdf"
}
Response: DocumentResponse
Remove a document from the library and all session attachments.
Attach a document from the library to a session.Request:
{
  "document_id": "doc-abc123"
}
Detach a document from a session (does not delete the document).

Files

Open a file or folder in the system file explorer. On Windows this launches Explorer, on macOS it uses open, and on Linux it uses xdg-open. Symbolic links are rejected for security.Request:
{
  "path": "/home/user/docs/report.pdf",
  "reveal": true
}
FieldTypeDescription
pathstringAbsolute path to the file or folder to open.
revealbooleanIf true (default), reveal the file selected in its parent folder. If false, open the containing folder directly. Ignored when path is a directory.
Response:
{
  "status": "ok",
  "path": "/home/user/docs/report.pdf"
}
Error responses:
StatusCondition
400Invalid path (empty or contains null bytes), or path is a symbolic link
404Path does not exist
500Failed to launch the system file explorer

Database Schema

The Agent UI uses SQLite with four tables:
-- Global document library
CREATE TABLE documents (
    id TEXT PRIMARY KEY,
    filename TEXT NOT NULL,
    filepath TEXT NOT NULL,
    file_hash TEXT UNIQUE NOT NULL,
    file_size INTEGER DEFAULT 0,
    chunk_count INTEGER DEFAULT 0,
    indexed_at TEXT,
    last_accessed_at TEXT,
    indexing_status TEXT DEFAULT 'complete',  -- pending | indexing | complete | failed | cancelled
    file_mtime REAL                          -- file modification time (Unix epoch)
);

-- Sessions (conversations)
CREATE TABLE sessions (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL DEFAULT 'New Chat',
    created_at TEXT,
    updated_at TEXT,
    model TEXT NOT NULL DEFAULT 'Qwen3.5-35B-A3B-GGUF',
    system_prompt TEXT
);

-- Many-to-many: documents attached to sessions
CREATE TABLE session_documents (
    session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
    document_id TEXT REFERENCES documents(id) ON DELETE CASCADE,
    attached_at TEXT,
    PRIMARY KEY (session_id, document_id)
);

-- Messages
CREATE TABLE messages (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE,
    role TEXT CHECK(role IN ('user', 'assistant', 'system')) NOT NULL,
    content TEXT NOT NULL,
    created_at TEXT,
    rag_sources TEXT,          -- JSON array of source citations
    agent_steps TEXT,          -- JSON array of agent execution steps
    tokens_prompt INTEGER,
    tokens_completion INTEGER
);
SQLite settings: Foreign keys enabled, WAL journal mode for concurrent reads.
The indexing_status, file_mtime, and agent_steps columns are added via migrations for databases created before these columns existed. New databases include them in the initial schema.

Testing

Unit Testing with In-Memory Database

import pytest
from fastapi.testclient import TestClient
from gaia.ui.server import create_app

@pytest.fixture
def client():
    """Create test client with in-memory database."""
    app = create_app(db_path=":memory:")
    return TestClient(app)

def test_create_session(client):
    resp = client.post("/api/sessions", json={"title": "Test Chat"})
    assert resp.status_code == 200
    data = resp.json()
    assert data["title"] == "Test Chat"
    assert data["message_count"] == 0

def test_send_message(client):
    # Create session first
    session = client.post("/api/sessions", json={}).json()
    session_id = session["id"]

    # Send a non-streaming message
    resp = client.post("/api/chat/send", json={
        "session_id": session_id,
        "message": "Hello",
        "stream": False,
    })
    assert resp.status_code == 200

def test_document_lifecycle(client):
    # This test requires a real file on disk
    pass

def test_health_check(client):
    resp = client.get("/api/health")
    assert resp.status_code == 200
    assert resp.json()["status"] == "ok"

Database Testing

from gaia.ui.database import ChatDatabase

def test_session_crud():
    db = ChatDatabase(":memory:")

    # Create
    session = db.create_session(title="Test")
    assert session["title"] == "Test"

    # Read
    fetched = db.get_session(session["id"])
    assert fetched is not None

    # Update
    updated = db.update_session(session["id"], title="Updated")
    assert updated["title"] == "Updated"

    # Delete
    assert db.delete_session(session["id"]) is True
    assert db.get_session(session["id"]) is None

    db.close()

def test_message_ordering():
    db = ChatDatabase(":memory:")
    session = db.create_session()

    db.add_message(session["id"], "user", "First")
    db.add_message(session["id"], "assistant", "Second")
    db.add_message(session["id"], "user", "Third")

    messages = db.get_messages(session["id"])
    assert len(messages) == 3
    assert messages[0]["content"] == "First"
    assert messages[2]["content"] == "Third"

    db.close()

Integration with the Agent

The Agent UI server delegates to the GAIA Agent for LLM communication and tool execution:
from gaia.agents.chat.agent import ChatAgent, ChatAgentConfig

# The server creates a ChatAgent instance per request
config = ChatAgentConfig(
    model_id=session.get("model", "Qwen3.5-35B-A3B-GGUF"),
    system_prompt=session.get("system_prompt"),
    allowed_paths=["/path/to/files"],
)
agent = ChatAgent(config)

# Process a query (agent reasons, uses tools, returns response)
result = agent.process_query(message)
Document indexing uses the RAG SDK:
from gaia.rag.sdk import RAGSDK, RAGConfig

config = RAGConfig()
rag = RAGSDK(config)
result = rag.index_document(filepath)
chunk_count = result.get("num_chunks", 0)

npm Package

GAIA Agent UI is also available as an npm package for quick installation:
npm install -g @amd-gaia/agent-ui
This provides the gaia-ui CLI command:
gaia-ui              # Start Python backend + open browser
gaia-ui --serve      # Serve frontend only (Node.js static server)
gaia-ui --port 8080  # Custom port
gaia-ui --version    # Show version
On first run, gaia-ui automatically installs the Python backend (uv, Python 3.12, amd-gaia) if not already present. On subsequent runs, it auto-updates if the version doesn’t match.

Package Contents

The npm package includes:
PathDescription
bin/gaia-ui.mjsCLI entry point (Node.js)
dist/Pre-built frontend (React SPA)

Release Management

The package version is sourced from src/gaia/version.py (single source of truth for all of GAIA):
# Check version consistency (package.json matches version.py)
node installer/version/bump-ui-version.mjs --check

# Sync package.json to version.py
node installer/version/bump-ui-version.mjs

# Full release (sync, commit, tag, push)
node installer/version/release-ui.mjs
Tags matching v* trigger the automated npm publish workflow.