# Project export: EmotionTalk

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: Cal Hacks 12.0
- Tagline: Your own AI emotional sales person
- Devpost: https://devpost.com/software/emotionttalk
- GitHub: https://github.com/aniketgarg1/CalHacks12.0.git
- Demo: https://www.canva.com/design/DAG23NPr_nM/VdGY9LOtXORUvUldAseFrQ/edit?utm_content=DAG23NPr_nM&utm_campaign=designshare&utm_medium=link2&utm_source=sharebutton
- Video: https://www.youtube.com/embed/sK7bxsa2oic?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 1 GitHub contributor(s) — aniketgarg1 (1 commits)

## Devpost submission (written by the team)

No Devpost description available.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 7 recognized source files, 22 KB.
- Anthropic (technology) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- TypeScript (language) — detected in the code
- CSS (language) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (13 of 13)

```
.gitignore
backend/.env.example
backend/package.json
backend/src/claude.ts
backend/src/index.ts
backend/src/routes/analyze.ts
backend/src/routes/config.ts
backend/src/routes/summary.ts
backend/tsconfig.json
frontend/public/index.html
frontend/public/main.js
frontend/vapi-ai-web-2.5.0.tgz
LICENSE
```

### Dependencies

- backend/package.json: @anthropic-ai/sdk@^0.67.0, @types/cors@^2.8.17, @types/express@^5.0.4, @types/node@^24.9.1, cors@^2.8.5, dotenv@^17.2.3, express@^5.1.0, nodemon@^3.1.0, ts-node@^10.9.2, typescript@^5.9.3, zod@^4.1.12

### Recent commits (newest first)

- updated name
- Initial clean import (no secrets)

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

### backend/package.json

```
{
  "name": "tone-coach-backend",
  "version": "1.0.0",
  "private": true,
  "type": "commonjs",
  "scripts": {
    "dev": "nodemon --watch src --exec ts-node src/index.ts"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.67.0",
    "cors": "^2.8.5",
    "dotenv": "^17.2.3",
    "express": "^5.1.0",
    "zod": "^4.1.12"
  },
  "devDependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^5.0.4",
    "@types/node": "^24.9.1",
    "nodemon": "^3.1.0",
    "ts-node": "^10.9.2",
    "typescript": "^5.9.3"
  }
}

```

### backend/src/index.ts

```typescript
import express from 'express';
import cors from 'cors';
import { analyze } from './routes/analyze';
import { summary } from './routes/summary';
import { config as configRoute } from './routes/config';


const app = express();
app.use(cors());
app.use(express.json());

app.use('/api/analyze', analyze);
app.use('/api/summary', summary);
app.use('/api/config', configRoute);
app.get('/health', (_, res) => res.json({ ok: true }));


app.listen(process.env.PORT || 4000, () =>
  console.log(`API on http://localhost:${process.env.PORT || 4000}`)
);

```

### frontend/public/main.js

```javascript
// -------------------------------------------------------------
// ToneCoach front-end  (clean UI, customer-only + debounced coaching)
// -------------------------------------------------------------

const API = 'http://localhost:4000';

let CFG = { vapiPublicKey: '', vapiAssistantId: '' };
let vapi = null;
let listening = false;

// conversation log for summary
const convo = []; // { speaker: 'customer' | 'owner', text: string }

// ---------- Tiny UI helpers ----------
const $ = (id) => document.getElementById(id);
const toneClass = (tone) => 'tone tone-' + String(tone || 'neutral').toLowerCase();
const setStatus = (s) => { const el = $('status'); if (el) el.textContent = s; };
const emojiMap = {
  frustrated: '😠', anxious: '😟', defensive: '😤', uncertain: '🤔',
  neutral: '😐', calm: '🙂', upbeat: '😄', confident: '😎'
};
const setEmoji = (tone) => { const el = $('emoji'); if (el) el.textContent = emojiMap[tone] || '😐'; };
const fail = (msg) => { console.error(msg); alert(msg); };

// ---------- Tabs ----------
function gotoTab(key) {
  document.querySelectorAll('.tab').forEach(t => {
    t.classList.toggle('active', t.getAttribute('data-screen') === key);
  });
  document.querySelectorAll('section[id^="screen-"]').forEach(sec => {
    sec.style.display = sec.id === 'screen-' + key ? '' : 'none';
  });
}
(function wireTabs() {
  const tabs = document.querySelectorAll('.tab');
  tabs.forEach((tab) => {
    tab.addEventListener('click', () => gotoTab(tab.getAttribute('data-screen')));
  });
})();

// ---------- Advice UI ----------
function showAdvice(out) {
  setEmoji(out.tone);
  const badge = $('toneBadge');
  if (badge) {
    badge.textContent = out.tone;
    badge.className = toneClass(out.tone);
  }
  const ul = $('bullets');
  if (ul) {
    ul.innerHTML = '';
    (out.bullets || []).forEach((b) => {
      const li = document.createElement('li');
      li.textContent = b;
      ul.appendChild(li);
    });
  }
  const why = $('why');
  if (why) why.textContent = 'Why: ' + out.evidence + ' • Confidence: ' + out.confidence + '/5';
}

// ---------- Speaker utils ----------
function lower(x){ return String(x||'').toLowerCase(); }

function extractRawSpeaker(evt){
  let r = evt?.role ?? evt?.speaker ?? evt?.participant ?? evt?.channel;
  if (r) return String(r);
  if (evt?.metadata?.speaker !== undefined) return String(evt.metadata.speaker);
  if (evt?.metadata?.channel !== undefined) return String(evt.metadata.channel);
  if (Array.isArray(evt?.messages) && evt.messages.length) {
    const last = evt.messages[evt.messages.length-1];
    if (last?.role) return String(last.role);
  }
  if (Array.isArray(evt?.conversation) && evt.conversation.length) {
    const last = evt.conversation[evt.conversation.length-1];
    if (last?.role) return String(last.role);
  }
  return '';
}

// CUSTOMER-only coaching.
// Treat Vapi assistant speech as 'customer'; everything else as 'owner'.
function mapSide(evt) {
  const role = lower(extractRawSpeaker(evt));
  const isAssistant = role === 'assistant'; // Vapi's spoken output
  return isAssistant ? 'customer' : 'owner';
}

// ---------- Coaching throttles (debounce + cooldown) ----------
const ANALYZE_DEBOUNCE_MS = 1200;  // wait this quiet time before analyzing
const ANALYZE_COOLDOWN_MS = 4000;  // minimum gap between analyses
const MIN_CUSTOMER_CHARS   = 12;   // ignore tiny blurts

let customerBuffer = '';
let debounceTimer  = null;
let lastAnalyzeAt  = 0;

function queueCustomerText(t) {
  customerBuffer = customerBuffer ? (customerBuffer + ' ' + t) : t;
  if (debounceTimer) clearTimeout(debounceTimer);
  debounceTimer = setTimeout(flushCustomerAnalysis, ANALYZE_DEBOUNCE_MS);
}

async function flushCustomerAnalysis() {
  debounceTimer = null;

  const now = Date.now();
  if (now - lastAnalyzeAt < ANALYZE_COOLDOWN_MS) return;
  const text = (customerBuffer || '').trim();
  if (text.length < MIN_CUSTOMER_CHARS) return;

  try {
    const r = await fetch(API + '/api/analyze', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text, context: 'agentic-ai', side: 'customer' })
    });
    const j = await r.json();
    if (j && j.ok) showAdvice(j);
    lastAnalyzeAt = Date.now();
  } catch (e) {
    console.error('Analyze error:', e);
  } finally {
    customerBuffer = '';
  }
}

// ---------- Transcript → log + (customer-only) analyze (debounced) ----------
async function handleTranscript(evt) {
  const text = evt?.text ?? evt?.transcript ?? evt?.output ?? '';
  const isFinal =
    (evt?.is_final === true) ||
    (evt?.final === true) ||
    (lower(evt?.transcriptType) === 'final') ||
    (lower(evt?.status) === 'final');

  if (!text || !isFinal) return;

  const side = mapSide(evt); // 'customer' | 'owner'

  // Log both sides for summary
  convo.push({ speaker: side, text });
  const sj = $('summaryJson');
  if (sj) sj.value = JSON.stringify(convo, null, 2);

  // LIVE coaching ONLY for customer lines (debounced)
  if (side === 'customer') {
    queueCustomerText(text);
    // quick flush if sentence clearly ends
    if (/[.?!]$/.test(text.trim())) {
      if (debounceTimer) clearTimeout(debounceTimer);
      debounceTimer = setTimeout(flushCustomerAnalysis, 300);
    }
  }
}

// ---------- Summarize ----------
async function doSummarize() {
  try {
    const r = await fetch(API + '/api/summary', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ utterances: convo })
    });
    const j = await r.json();
    $('summaryOut').textContent = j.summary || '(no summary)';
    gotoTab('summary');
  } catch (e) {
    fail('Network error calling /api/summary');
  }
}
$('summaryBtn')?.addEventListener('click', doSummarize);

// ---------- Vapi ----------
async function startVapi() {
  try {
    if (!CFG.vapiPublicKey || !CFG.vapiAssistantId) {
      return fail('Vapi config missing from backend (/api/config).');
    }
    if (!window.createVapi
[truncated — 2749 more characters]
```

### backend/src/claude.ts

```typescript
import 'dotenv/config';
import Anthropic from '@anthropic-ai/sdk';
export const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
export const MODEL = process.env.CLAUDE_MODEL || 'claude-3-7-sonnet-latest';

```

### frontend/public/index.html

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>EmotionTalk</title>

  <style>
    :root{
      /* Primary blues */
      --accent: #3b82f6;
      --accent-2:#2563eb;
      --accent-3:#60a5fa;

      /* Mic cyan (distinct from primary) */
      --mic:#06b6d4;
      --mic-2:#0891b2;

      /* Surface */
      --bg: #0a1020;
      --bg2:#0a1226;
      --glass: rgba(15, 20, 34, .55);
      --card: rgba(19, 24, 39, .66);
      --border: rgba(255,255,255,.08);
      --muted:#9ca3af;
      --text:#e8ecf3;
    }

    *{box-sizing:border-box}
    html,body{height:100%}
    body{
      margin:0; color:var(--text);
      font-family:-apple-system,BlinkMacSystemFont,"SF Pro Text","Segoe UI",Inter,Roboto,Arial,system-ui,sans-serif;
      letter-spacing:.1px;
      background:
        radial-gradient(1200px 800px at 20% -10%, rgba(59,130,246,.14), transparent),
        radial-gradient(900px 600px at 110% 10%, rgba(99,102,241,.10), transparent),
        linear-gradient(180deg, var(--bg2), var(--bg));
    }

    .wrap{max-width:1040px;margin:0 auto;padding:28px 20px 60px}

    /* Header */
    .header{display:flex;align-items:center;gap:16px;margin-bottom:10px}
    .logoMark{
      width:48px;height:48px;border-radius:14px;display:grid;place-items:center;flex:none;
      background:linear-gradient(135deg,var(--accent),var(--accent-3));
      box-shadow:0 10px 26px rgba(59,130,246,.34)
    }
    .logoMark svg{width:28px;height:28px;filter:drop-shadow(0 1px 1px rgba(0,0,0,.25))}
    .brand{
      display:flex;flex-direction:column;gap:2px
    }
    .wordmark{
      display:flex;align-items:baseline;gap:10px;line-height:1
    }
    .wordmark .brandE{color:var(--accent);font-weight:900;letter-spacing:.2px;font-size:28px}
    .wordmark .rest{color:var(--text);font-weight:800;letter-spacing:.2px;font-size:28px}
    .sub{color:var(--muted);font-size:12px;margin-top:2px}

    /* Tabs */
    .tabs{display:flex;gap:10px;margin:14px 0 18px}
    .tab{
      padding:10px 14px;border:1px solid var(--border);border-radius:12px;
      background:rgba(17,25,40,.58);cursor:pointer;transition:all .18s ease;color:#cfe2ff;
      font-weight:600
    }
    .tab:hover{border-color:#2b3a55;box-shadow:0 0 0 2px rgba(96,165,250,.12) inset}
    .tab.active{
      color:#fff;
      background:linear-gradient(180deg, rgba(59,130,246,.18), rgba(59,130,246,.06));
      outline:2px solid rgba(59,130,246,.55);
      text-shadow:0 1px 0 rgba(0,0,0,.25)
    }

    /* Shells */
    .frame{
      background:linear-gradient(180deg, rgba(255,255,255,.02), transparent),var(--glass);
      border:1px solid var(--border);border-radius:16px;
      box-shadow:0 16px 44px rgba(0,0,0,.30);backdrop-filter:blur(10px)
    }
    .container{padding:16px}
    .card{
      background:linear-gradient(180deg, rgba(255,255,255,.02), transparent), var(--card);
      border:1px solid var(--border);border-radius:16px;padding:16px
    }
    .muted{color:var(--muted);font-size:12px}

    .row{display:flex;gap:14px;flex-wrap:wrap;align-items:center}
    .grid{display:grid;gap:14px;grid-template-columns:1fr 1fr}
    @media (max-width:900px){.grid{grid-template-columns:1fr}}

    /* Inputs / buttons */
    textarea{
      width:100%;height:110px;background:#0b1324;color:var(--text);
      border:1px solid var(--border);border-radius:14px;padding:12px;outline:none;resize:vertical;
      font-size:14px
    }
    textarea:focus{border-color:var(--accent);box-shadow:0 0 0 2px rgba(59,130,246,.25)}

    .btn{
      background:linear-gradient(180deg,var(--accent),var(--accent-2));
      color:#fff;border:none;border-radius:12px;padding:10px 14px;cursor:pointer;font-weight:800;
      letter-spacing:.2px;transition:transform .08s ease, filter .12s ease,box-shadow .12s ease
    }
    .btn:hover{filter:brightness(1.06)}
    .btn:active{transform:translateY(1px)}
    .btn.ghost{background:transparent;border:1px solid var(--border);color:var(--text);font-weight:700}
    .btn.small{padding:8px 12px;border-radius:10px}

    /* Mic (clearly toggled) */
    .mic{
      width:60px;height:60px;border-radius:999px;display:grid;place-items:center;
      border:1px solid rgba(255,255,255,.14);
      background:linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
      font-size:22px;transition:all .18s ease;
      color:#d7e2ff;
    }
    .mic:hover{box-shadow:0 0 0 2px rgba(255,255,255,.10) inset}
    .mic.on{
      outline:3px solid rgba(6,182,212,.45);
      background:linear-gradient(180deg, rgba(6,182,212,.22), rgba(6,182,212,.08));
      color:#022a36;
      box-shadow:0 6px 18px rgba(6,182,212,.30), 0 0 0 0 rgba(6,182,212,.35);
      animation:pulse 1.6s ease-in-out infinite
    }
    @keyframes pulse{
      0%{box-shadow:0 6px 18px rgba(6,182,212,.30), 0 0 0 0 rgba(6,182,212,.35)}
      70%{box-shadow:0 6px 18px rgba(6,182,212,.30), 0 0 0 14px rgba(6,182,212,0)}
      100%{box-shadow:0 6px 18px rgba(6,182,212,.30), 0 0 0 0 rgba(6,182,212,0)}
    }

    /* Tone */
    .tone{display:inline-block;padding:6px 12px;border-radius:999px;font-weight:800;text-transform:capitalize;background:#334155}
    .tone-neutral{background:#334155}.tone-frustrated{background:#7f1d1d}.tone-anxious{background:#78350f}
    .tone-calm{background:#065f46}.tone-upbeat{background:#14532d}.tone-confident{background:#083344}
    .tone-defensive{background:#7c2d12}.tone-uncertain{background:#3f3f46}
    .emoji{font-size:46px;line-height:1}

    ul{margin:8px 0 0 20px;padding:0}
    pre{white-space:pre-wrap;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:13px}

    .topbar{display:flex;gap:12px;align-items:center}
    .spacer{flex:1}

    /* Headings */
    h3{margin:4px 0 10px;font-size:18px;letter-spacing:.2px}
  </style>
</head>
<body>
  <div class="wrap">
    <!-- Header with custom logo + wordmark -->
    <div class="header">
      <div class="logoMark" aria-labe
[truncated — 4129 more characters]
```

### backend/src/routes/config.ts

```typescript
import { Router } from 'express';
export const config = Router();

config.get('/', (_req, res) => {
  res.json({
    vapiPublicKey: process.env.VAPI_PUBLIC_KEY || '',
    vapiAssistantId: process.env.VAPI_ASSISTANT_ID || ''
  });
});

```

### backend/src/routes/summary.ts

```typescript
import { Router } from 'express';
import { anthropic, MODEL } from '../claude';


export const summary = Router();

summary.post('/', async (req, res) => {
  const utterances = (req.body?.utterances ?? []) as Array<{speaker:string,text:string}>;
  if (!utterances.length) return res.status(400).json({ ok: false, error: 'utterances[] required' });

  try {
    const r = await anthropic.messages.create({
      model: MODEL,
      max_tokens: 280,
      temperature: 0.3,
      messages: [{
        role: 'user',
        content:
`Create a 4-bullet coaching recap focusing on tone shifts and next steps.
Each bullet <= 14 words.

${JSON.stringify(utterances).slice(0,8000)}`
      }]
    });
    const text = r.content?.[0]?.type === 'text' ? (r.content[0] as any).text : '';
    res.json({ ok: true, summary: text });
  } catch (e: any) {
    res.status(500).json({ ok: false, error: e?.message ?? 'Claude error' });
  }
});

```

### backend/src/routes/analyze.ts

```typescript
import { Router } from 'express';
import { z } from 'zod';
import { anthropic, MODEL } from '../claude';

export const analyze = Router();

const Body = z.object({
  text: z.string().min(1),
  context: z.string().optional()
});

// helper: extract first {...} block and parse
function parseJsonFromText(t: string) {
  try { return JSON.parse(t); } catch {}
  const m = t.match(/\{[\s\S]*\}$/); // grab largest JSON-looking block
  if (m) { try { return JSON.parse(m[0]); } catch {} }
  return null;
}

analyze.post('/', async (req, res) => {
  const parsed = Body.safeParse(req.body);
  if (!parsed.success) return res.status(400).json({ ok: false, error: parsed.error.flatten() });
  const { text, context } = parsed.data;

  try {
    const msg = await anthropic.messages.create({
      model: MODEL,
      max_tokens: 180,
      temperature: 0, // <- deterministic
      system: 'You are a JSON generator. Return ONLY minified JSON. No prose. No markdown. No code fences.',
      messages: [{
        role: 'user',
        content:
`Fill this JSON for the CUSTOMER utterance below. Keep bullets <= 12 words.
Return exactly this shape (minified):
{"tone":"","confidence":3,"bullets":["","",""],"evidence":""}

Constraints:
- tone ∈ ["calm","confident","neutral","anxious","frustrated","defensive","uncertain","upbeat"]
- confidence ∈ 1..5
- bullets: 3 owner-facing actions, short and specific
- evidence: brief clause citing the customer's phrasing
Context: ${context ?? "general"}
Utterance: """${text}"""`}]
    });

    const raw = msg.content?.[0]?.type === 'text' ? (msg.content[0] as any).text : '';
    const data = parseJsonFromText(raw) ?? {
      tone: 'neutral',
      confidence: 3,
      bullets: ['Acknowledge once.', 'Give ETA + next step.', 'Offer small make-good.'],
      evidence: 'fallback parser'
    };

    res.json({ ok: true, ...data });
  } catch (e: any) {
    res.status(500).json({ ok: false, error: e?.message ?? 'Claude error' });
  }
});

```