# Project export: Euclid

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: UC Berkeley AI Hackathon 2025
- Tagline: A voice-enabled AI math tutor powered by Vapi, Claude, and Tldraw—delivering interactive, personalized guidance that builds students' confidence in real time, from anywhere.
- Devpost: https://devpost.com/software/euclid-fycqrx
- GitHub: https://github.com/andrewy1n/euclid
- Demo: https://euclid-eight.vercel.app/
- Team: 3 GitHub contributor(s) — andrewy1n (11 commits), Kate Fernandez (2 commits), constellation99 (1 commits)

## Devpost submission (written by the team)

### Inspiration

We built Euclid because everyone deserves high-quality, one-on-one math tutoring—no matter where they live or what resources they have available. Many students struggle with learning math, and while private tutoring can be greatly beneficial, the cost is often prohibitive. By combining voice interaction with real-time canvas snapshots, Euclid feels like it's truly present: speaking naturally, listening attentively, and annotating each student’s work. This lets it deliver instant, tailored guidance and personalized feedback. But Euclid does more than just solve math problems—it encourages students with gentle, confidence-boosting prompts, breaks every problem into bite-sized steps, and celebrates each win, keeping learners motivated. This dynamic, multimodal experience replicates the best parts of private tutoring while being more accessible than ever and far more effective than simple LLM Q&A.

### What it does

Euclid is a collaborative math problem space where students can: Upload notes or PDFs to generate practice problems. Sketch solutions on a whiteboard-like interface powered by Tldraw. Ask for help from an AI tutor who can view their current whiteboard and provide hints. Check their work, get feedback, and receive new problems based on their progress. Everything happens in-context—on the canvas—through arrows, equations, and agent feedback, mimicking how a tutor might work alongside you on a physical whiteboard.

### How we built it

Frontend: React + Tldraw for the canvas-based UI, using custom shapes and math rendering via KaTeX. Conversational Agent: Built with Vapi, using Claude 4 with vision to process current whiteboard state and provide hints, guidance, or feedback. Practice Problem Generation: Claude 4 processes uploaded PDFs of math notes to extract concepts and generate new LaTeX-formatted practice questions. Authentication and Storage: Supabase stores user accounts, problem spaces, and canvas snapshots for each question.

### Challenges we ran into

Getting usable context into the LLM agent: we explored both passing images of the canvas and using snapshot.json state.

### Accomplishments we're proud of

Created a somewhat working and deployed web application

### What we learned

Tldraw is very cool but also incredibly complicated especially when it comes to saving and loading canvas states. Using vision description as context worked surprisingly well when it comes to getting context from the canvas to the voice agent.

### What's next

Add a social layer: allow users to share their problem spaces with friends or tutors for collaborative solving. Improve agent grounding: extract and annotate key steps or errors in student work over time.

## README (from the GitHub repository)

# React + TypeScript + Vite

This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.

Currently, two official plugins are available:

- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

## Expanding the ESLint configuration

If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:

```js
export default tseslint.config({
  extends: [
    // Remove ...tseslint.configs.recommended and replace with this
    ...tseslint.configs.recommendedTypeChecked,
    // Alternatively, use this for stricter rules
    ...tseslint.configs.strictTypeChecked,
    // Optionally, add this for stylistic rules
    ...tseslint.configs.stylisticTypeChecked,
  ],
  languageOptions: {
    // other options...
    parserOptions: {
      project: ['./tsconfig.node.json', './tsconfig.app.json'],
      tsconfigRootDir: import.meta.dirname,
    },
  },
})
```

You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:

```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'

export default tseslint.config({
  plugins: {
    // Add the react-x and react-dom plugins
    'react-x': reactX,
    'react-dom': reactDom,
  },
  rules: {
    // other rules...
    // Enable its recommended typescript rules
    ...reactX.configs['recommended-typescript'].rules,
    ...reactDom.configs.recommended.rules,
  },
})
```


## Detected evidence (automated analysis)

Indexed codebase: 27 recognized source files, 109 KB.
- CSS (language) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- Python (language) — detected in the code
- React (technology) — detected in the code
- Supabase (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Anthropic (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (33 of 33)

```
.gitignore
eslint.config.js
index.html
mcp_server.py
package.json
README.md
src/App.tsx
src/components/ExportCanvasButton.tsx
src/components/failure-modal.tsx
src/components/FileModal.tsx
src/components/homepage/ProgressBar.tsx
src/components/homepage/SessionCard.tsx
src/components/homepage/SessionList.tsx
src/components/homepage/StatusChip.tsx
src/components/homepage/UploadBox.tsx
src/components/success-modal.tsx
src/components/ThreeJSLanding.tsx
src/components/UserProfileDropdown.tsx
src/constants/snapshot.json
src/index.css
src/main.tsx
src/pages/Home.tsx
src/pages/ProblemSpace.tsx
src/services/canvas.ts
src/services/claude.ts
src/services/vapi.ts
src/util/supabase.ts
src/vite-env.d.ts
tailwind.config.js
tsconfig.app.json
tsconfig.json
tsconfig.node.json
vite.config.ts
```

### Dependencies

- package.json: @eslint/js@^9.25.0, @react-three/drei@^10.3.0, @react-three/fiber@^9.1.2, @supabase/auth-ui-react@^0.4.7, @supabase/auth-ui-shared@^0.1.8, @supabase/supabase-js@^2.50.0, @tailwindcss/vite@^4.1.10, @types/react@^19.1.2, @types/react-dom@^19.1.2, @types/react-katex@^3.0.4, @types/three@^0.177.0, @vapi-ai/web@^2.3.6, @vitejs/plugin-react@^4.4.1, eslint@^9.25.0, eslint-plugin-react-hooks@^5.2.0, eslint-plugin-react-refresh@^0.4.19, globals@^16.0.0, katex@^0.16.22, react@^19.1.0, react-dom@^19.1.0, react-katex@^3.1.0, react-router-dom@^7.6.2, tailwindcss@^4.1.10, three@^0.177.0, tldraw@^3.13.1, typescript@~5.8.3, typescript-eslint@^8.30.1, vite@^6.3.5

### Recent commits (newest first)

- Merge branch 'main' of https://github.com/andrewy1n/euclid
- fixed text box styling
- Merge pull request #1 from constellation99/main
- Adding mcp server
- remove unused
- styling
- vapi
- fixed saving
- snapshot saving partially working
- new changes
- saving LOl
- init

## Key source files (fetched from GitHub, selected and truncated for size)

### package.json

```
{
  "name": "euclid",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "lint": "eslint .",
    "preview": "vite preview"
  },
  "dependencies": {
    "@react-three/drei": "^10.3.0",
    "@react-three/fiber": "^9.1.2",
    "@supabase/auth-ui-react": "^0.4.7",
    "@supabase/auth-ui-shared": "^0.1.8",
    "@supabase/supabase-js": "^2.50.0",
    "@tailwindcss/vite": "^4.1.10",
    "@types/three": "^0.177.0",
    "@vapi-ai/web": "^2.3.6",
    "katex": "^0.16.22",
    "react": "^19.1.0",
    "react-dom": "^19.1.0",
    "react-katex": "^3.1.0",
    "react-router-dom": "^7.6.2",
    "tailwindcss": "^4.1.10",
    "three": "^0.177.0",
    "tldraw": "^3.13.1"
  },
  "devDependencies": {
    "@eslint/js": "^9.25.0",
    "@types/react": "^19.1.2",
    "@types/react-dom": "^19.1.2",
    "@types/react-katex": "^3.0.4",
    "@vitejs/plugin-react": "^4.4.1",
    "eslint": "^9.25.0",
    "eslint-plugin-react-hooks": "^5.2.0",
    "eslint-plugin-react-refresh": "^0.4.19",
    "globals": "^16.0.0",
    "typescript": "~5.8.3",
    "typescript-eslint": "^8.30.1",
    "vite": "^6.3.5"
  }
}

```

### src/main.tsx

```typescript
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

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

```

### src/App.tsx

```typescript
import './index.css'
import { useState, useEffect } from 'react'
import type { Session } from '@supabase/supabase-js'
import { Auth } from '@supabase/auth-ui-react'
import { ThemeSupa } from '@supabase/auth-ui-shared'
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
import Home from './pages/Home'
import ProblemSpace from './pages/ProblemSpace'
import ThreeJSLanding from './components/ThreeJSLanding'
import { supabase, getSession, onAuthStateChange } from './util/supabase'

function LoginPage({ onBack }: { onBack: () => void }) {
  return (
    <div className="min-h-screen bg-black flex items-center justify-center p-4">
      {/* Animated background gradient */}
      <div className="absolute inset-0 bg-gradient-to-br from-blue-900/10 via-purple-900/10 to-indigo-900/10 animate-pulse"></div>
      
      <div className="relative z-10 bg-gray-900/50 backdrop-blur-sm p-8 rounded-lg shadow-2xl max-w-md w-full border border-gray-700">
        <div className="flex justify-between items-center mb-6">
          <h1 className="text-2xl font-bold text-white font-karla">Login</h1>
          <button 
            onClick={onBack}
            className="text-gray-400 hover:text-white text-lg transition-colors"
          >
            ✕
          </button>
        </div>
        <Auth 
          supabaseClient={supabase} 
          appearance={{ 
            theme: ThemeSupa,
            variables: {
              default: {
                colors: {
                  brand: '#3b82f6',
                  brandAccent: '#1d4ed8',
                  brandButtonText: '#ffffff',
                  defaultButtonBackground: '#374151',
                  defaultButtonBackgroundHover: '#4b5563',
                  defaultButtonBorder: '#6b7280',
                  defaultButtonText: '#ffffff',
                  dividerBackground: '#374151',
                  inputBackground: '#1f2937',
                  inputBorder: '#4b5563',
                  inputBorderHover: '#6b7280',
                  inputBorderFocus: '#3b82f6',
                  inputText: '#ffffff',
                  inputLabelText: '#d1d5db',
                  inputPlaceholder: '#9ca3af',
                  messageText: '#d1d5db',
                  messageTextDanger: '#fca5a5',
                  anchorTextColor: '#60a5fa',
                  anchorTextHoverColor: '#93c5fd',
                },
                space: {
                  inputPadding: '12px',
                  buttonPadding: '12px',
                },
                fontSizes: {
                  baseBodySize: '14px',
                  baseInputSize: '14px',
                  baseLabelSize: '14px',
                  baseButtonSize: '14px',
                },
                fonts: {
                  bodyFontFamily: 'Inter, system-ui, sans-serif',
                  buttonFontFamily: 'Inter, system-ui, sans-serif',
                  inputFontFamily: 'Inter, system-ui, sans-serif',
                  labelFontFamily: 'Inter, system-ui, sans-serif',
                },
                borderWidths: {
                  buttonBorderWidth: '1px',
                  inputBorderWidth: '1px',
                },
                radii: {
                  borderRadiusButton: '8px',
                  buttonBorderRadius: '8px',
                  inputBorderRadius: '8px',
                },
              },
            },
          }} 
        />
      </div>
    </div>
  )
}

export default function App() {
  const [session, setSession] = useState<Session | null>(null)
  const [showLogin, setShowLogin] = useState(false)

  useEffect(() => {
    getSession().then(({ session }) => {
      setSession(session)
    })

    const { data: { subscription } } = onAuthStateChange(async (event, session) => {
      console.log('Auth state change:', event, session?.user?.id)
      setSession(session)
    })

    return () => subscription.unsubscribe()
  }, [])

  return (
    <Router>
      <Routes>
        <Route 
          path="/" 
          element={
            session ? (
              <Navigate to="/home" replace />
            ) : showLogin ? (
              <LoginPage onBack={() => setShowLogin(false)} />
            ) : (
              <ThreeJSLanding onShowLogin={() => setShowLogin(true)} />
            )
          } 
        />
        <Route 
          path="/home" 
          element={
            session ? (
              <Home session={session} />
            ) : (
              <Navigate to="/" replace />
            )
          } 
        />
        <Route 
          path="/problem-space/:sessionId" 
          element={
            session ? (
              <ProblemSpace />
            ) : (
              <Navigate to="/" replace />
            )
          } 
        />
      </Routes>
    </Router>
  )
}
```

### vite.config.ts

```typescript
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

// https://vite.dev/config/
export default defineConfig({
  plugins: [react(), tailwindcss()],
})

```

### tailwind.config.js

```javascript
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      fontFamily: {
        'karla': ['Karla', 'sans-serif'],
        'ibm-plex': ['IBM Plex Sans', 'sans-serif'],
      },
    },
  },
  plugins: [],
} 
```

### eslint.config.js

```javascript
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'

export default tseslint.config(
  { ignores: ['dist'] },
  {
    extends: [js.configs.recommended, ...tseslint.configs.recommended],
    files: ['**/*.{ts,tsx}'],
    languageOptions: {
      ecmaVersion: 2020,
      globals: globals.browser,
    },
    plugins: {
      'react-hooks': reactHooks,
      'react-refresh': reactRefresh,
    },
    rules: {
      ...reactHooks.configs.recommended.rules,
      'react-refresh/only-export-components': [
        'warn',
        { allowConstantExport: true },
      ],
    },
  },
)

```

### index.html

```html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Euclid</title>
    <link rel="preconnect" href="https://fonts.googleapis.com">
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
    <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;1,100;1,200;1,300;1,400;1,500;1,600;1,700&family=Karla:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;0,800;1,200;1,300;1,400;1,500;1,600;1,700;1,800&display=swap" rel="stylesheet">
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

```

### src/vite-env.d.ts

```typescript
/// <reference types="vite/client" />

declare module "*.svg" {
  const content: string;
  export default content;
}

```

### src/index.css

```css
@import "tailwindcss";
@import "katex/dist/katex.min.css";
@tailwind base;
@tailwind components;
@tailwind utilities;

/* KaTeX text wrapping styles */
.katex-display {
  overflow-wrap: break-word;
  word-wrap: break-word;
  word-break: break-word;
  hyphens: auto;
  max-width: 100%;
}

.katex {
  font-size: 1em;
  line-height: 1.2;
}

/* Ensure BlockMath containers handle overflow properly */
.katex-display > .katex {
  display: inline-block;
  text-align: left;
  max-width: 100%;
  overflow-x: auto;
  overflow-y: hidden;
}

```

### mcp_server.py

```python
# server.py
import os
import asyncio
import base64
import io
import json
from typing import Any, Dict

from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.concurrency import run_in_threadpool
import uvicorn

from PIL import Image, ImageDraw, ImageFont
import pytesseract

from vapi import Vapi

# ─── CONFIG ─────────────────────────────────────────────────────────
VAPI_KEY = os.getenv("VAPI_KEY")
if not VAPI_KEY:
    raise RuntimeError("Set VAPI_KEY in environment")
WS_PORT   = 4444
HTTP_PORT = 3333

# ─── INIT ───────────────────────────────────────────────────────────
# app = FastAPI()
vapi = Vapi(token=VAPI_KEY)  # client
clients = set()   # for JSON-RPC broadcast

app = FastAPI(
    websocket_allowed_origins=["*"],    # ← allow any ws:// client
)

# still add CORS for your HTTP endpoints if you like
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
    allow_credentials=True,
)

# ─── YOUR EUCLID "SYSTEM" PROMPT & FUNCTION DEFINITIONS ────────────
EUCLID_PROMPT = """
You are Euclid, a voice-enabled AI math tutor whose mission is to help students learn by doing.
You speak in a friendly, patient, and upbeat tone, breaking every concept into bite-size steps.
Always prioritize guiding the student—never just give away the answer.

────────────────────────────────────
TUTORING GOALS
────────────────────────────────────
• **Empower**: Encourage students to think and try each step themselves.  
• **Clarify**: Restate and simplify problems so they're crystal clear.  
• **Support**: Catch common errors, offer hints, and build confidence.  
• **Adapt**: Tailor difficulty in real time based on student responses.

────────────────────────────────────
PROBLEM WORKFLOW
────────────────────────────────────
1. **LISTEN & RESTATE**  
2. **PLAN OUTLOUD**  
3. **STEP-BY-STEP COACHING**  
4. **VALIDATE WITH TOOLS**  
5. **FEEDBACK LOOP**  
6. **SUMMARIZE & EXTEND**

────────────────────────────────────
VOICE-UI BEST PRACTICES
────────────────────────────────────
• **Short bursts** (≤ 12 words)  
• **Natural pauses** ("…")  
• **Conversational cues** ("You've got this," "Nicely done")  
• **Energy level**: warm & encouraging

────────────────────────────────────
ADAPTIVITY STRATEGIES
────────────────────────────────────
• After two failed attempts, offer a simpler example.  
• If they breeze through three steps, offer a challenge.  
• Periodically check confidence: "Do you feel ready to move on?"

────────────────────────────────────
TOOL & ERROR-HANDLING
────────────────────────────────────
• **Math tool**: Always defer arithmetic to `calculate(expression)`.  
• **Fallback**: If transcription fails, say "I'm sorry, could you repeat that?"  
• **Error correction**: Point out slips immediately ("I notice the minus sign is missing…").

────────────────────────────────────
CANVAS INTEGRATION
────────────────────────────────────
When you receive a JSON payload:
```json
{
  "type":"canvas_snapshot",
  "image_data":"data:image/png;base64,…",
  "canvas":{"width":1200,"height":800},
  "export_opts":{"scale":2}
}
```

1. Call analyze_canvas_image(image_data, canvas, export_opts) to OCR & parse their work.

2. Use returned coordinates to call circle_region(x,y,radius,color) or draw_text(x,y,text,fontSize) for annotations.

3. After each annotation, describe out loud: "I've circled the error in your second term…"

────────────────────────────────────
CANVAS ANNOTATION (OPTIONAL)
────────────────────────────────────
When you want a fully marked-up image, call:
annotate_image(image_data, annotations)

• image_data: base64 PNG of the student's canvas  
• annotations: an array of objects, e.g.  
  [{type:"circle", x:100, y:150, radius:30},  
   {type:"text", x:120, y:200, text:"Check sign here"}]

The tool will return:
{ annotated_image: "data:image/png;base64,AAA…" }

After you get the result, respond in two ways:
1. Speak your feedback, e.g. "I've circled the error in your second term," or "Here's the corrected version of your work."
2. Return the annotated_image field so the front end can display it.

────────────────────────────────────
SESSION-START SNAPSHOT HANDLING
────────────────────────────────────
When the session begins with a canvas_snapshot payload, immediately:
1. Call `analyze_canvas_image(image_data, canvas, export_opts)`.  
2. From the returned `parsed_text`:
   – Greet the student: "Hi there! I see you're working on [parsed_text]."  
   – Ask: "Would you like me to walk you through each step, or focus on a specific part?"  
3. <wait for user response>  
4. If the user asks for a walkthrough:
   a. Outline the plan in 2–3 bullets.  
   b. For each step:
      – Explain the step (≤ 12 words).  
      – <wait for user response>  
      – Validate with `calculate(expression)`.  
   c. Continue until complete.  
5. If the user asks about a specific part, dive straight into that sub-step:
   – Explain the targeted step.  
   – <wait for user response>  
   – Validate with `calculate(expression)`.

────────────────────────────────────
ON-DEMAND SNAPSHOT FLOW
────────────────────────────────────
– Student clicks "Ask for help."
– Front-end exports a PNG at scale:2, encodes it to base64, and sends the above JSON.
– You analyze and annotate, then speak your feedback in sync with canvas updates.

────────────────────────────────────

[Additional Tips]
– Spell out numbers for natural speech: "twenty-two" not "22."
– Use Markdown formatting to keep sections clear.
"""

FUNCTIONS = [
    {
        "name": "analyze_canvas_image",
        "description": "OCR the student's canvas and locate potential mistakes.",
        "parameters": {
            "type": "object",
 
[truncated — 15744 more characters]
```

[15 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]