# Project export: Uni Pilot

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 2026
- Tagline: A small team of AI agents that quietly runs the busywork of university life, and gets smarter every time students use it.
- Devpost: https://devpost.com/software/uni-pilot
- GitHub: https://github.com/panda41983/uni-pilot
- Video: https://www.youtube.com/embed/BjVot-bZNeY?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — bkleinp (20 commits), Mary Laska (13 commits), Lukas Moser (4 commits), Claude Opus 4.8 (1 commits)

## Devpost submission (written by the team)

### Overview

Uni Pilot A small team of AI agents that quietly runs the busywork of college, and gets smarter every time another student uses it.

### Inspiration

Every college student loses hours each week to the same low-value busywork: decoding a syllabus to figure out how grades actually work, drafting the "can I get an extension?" email for the fifth time, manually copying due dates into a calendar, doing the mental math on "what do I need on the final to keep my A," and re-asking a question in the class forum that fifty people before you already answered. None of it is hard. All of it is friction. And it's the same friction for everyone in the class, which is the part that always bothered us. We wanted to build something that felt less like another productivity app you have to manage, and more like a small team of people working quietly on your behalf in the background.

### What it does

Uni Pilot is a platform where students activate single-purpose AI agents, each owning one slice of academic life. You browse a gallery, see what agents you'd like to use, add them to your homepage, and they start working away for you. Helping pilot your university life. The agents: 📅 Briefer: every morning, a rundown of what's due, what classes you have, and a ranked list of what to actually work on next. ✉️ Messenger: emails your prof for extensions, absences, regrade requests, or intros — with the right tone every time. 🗓️ Scheduler: turn "midterm Friday March 14 at 2pm in Wheeler 150" into a real Google calendar event, with conflict detection. 🔍 FAQ Finder: surfaces answers from your emails, Reddit, and Piazza so you're not digging through threads. 📖 Syllabus Summarizer: distills any course into a clean overview — grading breakdown, key dates, and Rate My Prof insights all in one place. 📊 Grade Evaluator: tracks your current average, breaks down performance by category, and tells you exactly where to focus your studying. 📝 Assignment Planner: reads the prompt so you don't have to panic — then gives you a concrete game plan for tackling it. And more to come!

### How we built it

Backend: Node.js + Express with an embedded SQLite database — a single-origin server that serves both the API and the frontend, so there's zero CORS/proxy setup. Frontend: A lightweight TypeScript app with a hash router, built around a calm, minimal aesthetic where agent activity always reads like a plain English sentence. AI: Anthropic's Claude Sonnet 4.6 powers the agents through a single choke-point LLM module. We lean on forced tool calls for reliable structured output (syllabus extraction, calendar parsing) and plain completions for narrative tasks (briefs, emails, grades). A strict division of labour: the LLM handles language and judgment; deterministic code handles anything that must be correct. Grades are computed by code, not the model. Backward scheduling is code. The model never invents a number or a link. Privacy by design: sensitive fields like grades and email content are encrypted at rest, and the whole app degrades gracefully — pull the API key and every agent falls back to deterministic behaviour instead of breaking. Live integrations: Canvas LMS (auto-import courses and grades), with scaffolding for Ed Discussion, Google Calendar, and email via Canvas.

### Challenges we ran into

Trust boundaries. An agent that emails your professor or writes to your calendar is terrifying if it acts on its own. We made a hard architectural rule: agents draft and propose, humans confirm and send. The email agent literally cannot send unless you explicitly tell it to. Keeping the AI in its lane. Early on, the model would happily "compute" a grade — and sometimes get it wrong. We redesigned so the LLM only ever narrates numbers that deterministic code produced. No embeddings endpoint. The Anthropic API doesn't offer embeddings, so for the FAQ's retrieval we built a fully-offline, deterministic bag-of-words hashing embedder — RAG with zero external dependencies. Graceful degradation. Making every single agent work without an API key (so a demo never hard-fails) meant writing a deterministic fallback for each one.

### Accomplishments we're proud of

Three working agents, each genuinely useful on its own, sharing one clean data backbone. An LLM architecture where the model never touches anything that has to be correct — so we get AI flexibility without AI unreliability. A setup flow that gets a student from "never seen this" to "active agent" in under two minutes. It actually feels calm. The dashboard makes coursework feel handled.

### What we learned

The best place for an LLM in a high-stakes workflow is often narrow: parsing messy human input into structure, and turning structured data back into friendly language — with deterministic code owning everything in between. That boundary is what makes the agents feel trustworthy instead of scary.

### What's next

Fully wire the collective-intelligence layer so cross-student answers surface automatically. An agent marketplace where students publish agents for their own courses. Proactive nudges — the brief that messages you when something's about to slip.

## README (from the GitHub repository)

# UniPilot

![Node.js](https://img.shields.io/badge/Node.js-%E2%89%A522.5-339933?logo=node.js&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-5.7-3178C6?logo=typescript&logoColor=white)
![Express](https://img.shields.io/badge/Express-4.x-000000?logo=express&logoColor=white)
![SQLite](https://img.shields.io/badge/SQLite-node%3Asqlite-003B57?logo=sqlite&logoColor=white)
![Anthropic Claude](https://img.shields.io/badge/Anthropic-Claude-D97757?logo=anthropic&logoColor=white)
![JavaScript](https://img.shields.io/badge/Frontend-Vanilla%20JS-F7DF1E?logo=javascript&logoColor=black)
![Google APIs](https://img.shields.io/badge/Google-Calendar%20%26%20Gmail-4285F4?logo=google&logoColor=white)
![Canvas](https://img.shields.io/badge/Canvas-LMS-E72429?logo=instructure&logoColor=white)
![Telegram](https://img.shields.io/badge/Telegram-Bot%20API-26A5E4?logo=telegram&logoColor=white)

**Quiet AI teammates for the parts of school you keep forgetting.**

UniPilot is a college-student productivity platform built around a handful of focused AI agents. It pulls your real academic life in from **Canvas**, **Ed Discussion**, and **Google Calendar**, then puts a few calm, single-purpose agents on top:

- **The Briefer** — texts a short "here's your day" to your phone every morning (via Telegram).
- **The Scheduler** — turn plain English ("office hours Tuesday 3pm") into calendar events; you confirm before anything is written.
- **The Messenger** — drafts emails to professors when you're sick or slammed. It never sends without you.
- **Syllabus Reader** — drop in a syllabus (PDF/DOCX/text) and it extracts grading, key dates, and policies, then lets you chat about the class.

Plus supporting engines: a grade evaluator, an assignment planner, and a RAG course tutor that answers questions from your own uploaded materials.

> Built for a hackathon. Everything degrades gracefully — the app runs and is usable even with **no** API keys configured (agents fall back to deterministic templates and local heuristics).

---

## Tech stack

| Layer | Choice |
| --- | --- |
| **Frontend** | Vanilla JS (no framework, no build step), a tiny hash router, hand-written CSS. Served as static files from `public/`. |
| **Backend** | Node.js + **Express** (TypeScript, ESM), run with `tsx` in dev and compiled with `tsc` for prod. |
| **Database** | **SQLite** via Node's built-in `node:sqlite` (no native deps / no compile toolchain). Schema in `server/src/db/schema.sql`. |
| **LLM** | **Anthropic Claude** (`@anthropic-ai/sdk`) for brief wording, message interpretation, email/answer drafting, and syllabus extraction. |
| **Integrations** | Canvas LMS (REST + token), Google Calendar + Gmail (OAuth via `googleapis`), Ed Discussion, Telegram Bot API. |
| **Parsing / utils** | `pdf-parse` + `mammoth` (syllabus/doc text), `rrule` (recurring events), `dayjs` (time), `zod` (request validation), `multer` (uploads), `nanoid` (ids). |
| **Security** | Sensitive fields (tokens, grades) encrypted at rest with AES-256-GCM (`server/src/crypto.ts`). |

The frontend and backend are served from a **single origin** (Express serves `public/` and falls back to the SPA shell), so there's no CORS/proxy setup needed in dev or prod.

---

## Prerequisites

- **Node.js >= 22.5** — required, because the backend uses the built-in `node:sqlite` module. Check with `node -v`.
- **npm** (ships with Node).
- Optional API keys (see [Configuration](#configuration)) — only needed to light up the LLM and live integrations.

---

## Quick start

```bash
# 1. Clone, then install root + server dependencies
npm run install:all

# 2. Set up environment (optional, but recommended)
cp .env.example .env
# open .env and fill in any keys you have (all optional — see below)

# 3. Run the dev server (tsx watch — restarts on changes)
npm run dev
```

Then open **http://localhost:4000**.

On first boot the server creates `data.db`, applies the schema, generates a local encryption key (`.enc-key`) if one isn't set, and — if Canvas credentials are present — imports your courses/assignments once.

### Try it out without any keys

Just run `npm run dev` and open the app. You can browse Today, Classes, Deploy, and the agent pages with seeded/fallback data. To populate realistic data, run the seed script:

```bash
npm run seed
```

### Trying the Syllabus Reader

1. Go to **Deploy** and click the **Syllabus Reader** card → its page opens.
2. **Choose a file** (PDF, DOCX, HTML, or `.txt`/`.md`) or expand **"or paste the syllabus text."**
3. Click **Read syllabus** — you'll get a structured summary (grading breakdown, key dates, policies, office hours, materials).
4. Use the chat box to ask anything about the class — answers are grounded strictly in that syllabus. *(Chat requires `ANTHROPIC_API_KEY`; the summary works without it via a regex fallback.)*

---

## Available scripts

Run from the repo root:

| Command | What it does |
| --- | --- |
| `npm run install:all` | Install root + `server/` dependencies. |
| `npm run dev` | Start the backend in watch mode (`tsx watch`) and serve the frontend at `:4000`. |
| `npm run build` | Type-check + compile the server to `server/dist/` (copies the SQL schema). |
| `npm run start` | Run the compiled production build (`node dist/index.js`). |
| `npm run seed` | Seed the SQLite DB with sample courses/assignments/data. |

---

## Configuration

Copy `.env.example` → `.env`. **Every value is optional**; missing keys just disable that capability.

| Variable | Purpose |
| --- | --- |
| `ANTHROPIC_API_KEY` | The "brain." Enables LLM brief wording, message interpretation, email/answer drafting, and syllabus extraction. Without it, agents use deterministic fallbacks. |
| `ANTHROPIC_MODEL` | Claude model name (default `claude-opus-4-8`). |
| `TELEGRAM_BOT_TOKEN` | The Briefer's channel. Create a bot with [@BotFather](https://t.me/BotFather), paste the token, then message your bot once to link it. |
| `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` | Google OAuth for the Scheduler (write to Google Calendar) and Gmail drafts. Enable the Calendar API, create a **Web** OAuth client, set redirect URI to `http://localhost:4000/api/auth/google/callback`, then click **Connect** in the app. |
| `GOOGLE_REDIRECT_URI` | Override the OAuth callback (defaults to the localhost URI above). |
| `CANVAS_BASE_URL` / `CANVAS_TOKEN` | Server-wide Canvas LMS access (alternatively, connect per-user in-app on the **You** page). |
| `ED_API_TOKEN` | Ed Discussion announcements/threads. |
| `PORT` | Backend port (default `4000`). |
| `ENCRYPTION_KEY` | 32-byte hex key for encrypting tokens/grades at rest. Generate: `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`. If unset, a key is generated and persisted to `.enc-key` on first run. |
| `DB_PATH` | Override the SQLite file location (default `server/../data.db`). |
| `RMP_ENABLED` / `REDDIT_TOKEN` | Best-effort enrichment sources (RateMyProfessors / Reddit), off by default. |

---

## Project structure

```
ai-hackathon-2026/
├── public/                 # Vanilla JS frontend (no build step)
│   ├── index.html          # App shell
│   ├── app.js              # Hash router + all views
│   ├── api.js              # Backend client + bootstrap/data mapping
│   ├── data.js             # Agent config + live state containers
│   ├── styles.css          # Hand-written styles
│   └── logo.svg / favicon.svg
├── server/
│   └── src/
│       ├── index.ts        # Express app, static serving, boot tasks
│       ├── http/           # API routes (routes.ts) + request context
│       ├── agents/         # brief, calendar, email, faq, grade, planner, syllabus
│       ├── integrations/   # canvas, google, ed, telegram, RateMyProf/Reddit
│       ├── db/             # SQLite adapter, repo layer, schema.sql, seed
│       ├── llm/            # Anthropic client wrapper
│       ├── domain/         # time/date helpers
│       ├── crypto.ts       # AES-256-GCM field encryption
│   

[README truncated for size]

## Detected evidence (automated analysis)

Indexed codebase: 38 recognized source files, 412 KB.
- Anthropic (technology) — detected in the code
- CSS (language) — detected in the code
- Express (technology) — detected in the code
- HTML (language) — detected in the code
- JavaScript (language) — detected in the code
- SQL (language) — detected in the code
- TypeScript (language) — detected in the code
- Flask (technology) — claimed on Devpost, not found in the code
- Node.js (technology) — claimed on Devpost, not found in the code
- Python (language) — claimed on Devpost, not found in the code
- React (technology) — claimed on Devpost, not found in the code
- AI coding agent: Claude Code — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (46 of 46)

```
.env.example
.gitignore
aicontext/overview.md
client/.vite/deps/_metadata.json
client/.vite/deps/package.json
package.json
public/api.js
public/app.js
public/data.js
public/index.html
public/styles.css
README.md
server/package.json
server/src/agents/brief.ts
server/src/agents/calendar.ts
server/src/agents/email.ts
server/src/agents/faq.ts
server/src/agents/grade.ts
server/src/agents/planner.ts
server/src/agents/syllabus.ts
server/src/ambient.d.ts
server/src/crypto.ts
server/src/db/index.ts
server/src/db/repo.ts
server/src/db/schema.sql
server/src/db/seed.ts
server/src/db/sqlite.ts
server/src/domain/grades.ts
server/src/domain/planner.ts
server/src/domain/ranking.ts
server/src/domain/time.ts
server/src/http/context.ts
server/src/http/routes.ts
server/src/index.ts
server/src/integrations/canvas.ts
server/src/integrations/ed.ts
server/src/integrations/gcal.ts
server/src/integrations/google.ts
server/src/integrations/index.ts
server/src/integrations/telegram.ts
server/src/llm/anthropic.ts
server/src/scheduler.ts
server/src/telegram-router.ts
server/src/types.ts
server/tsconfig.json
Syllabus.txt
```

### Dependencies

- server/package.json: @anthropic-ai/sdk@^0.32.1, @types/cors@^2.8.17, @types/express@^4.17.21, @types/multer@^1.4.12, @types/node@^22.10.5, cors@^2.8.5, dayjs@^1.11.13, express@^4.21.2, googleapis@^173.0.0, mammoth@^1.8.0, multer@^2.2.0, nanoid@^5.0.9, pdf-parse@^1.1.1, rrule@^2.8.1, tsx@^4.19.2, typescript@^5.7.3, zod@^3.24.1

### Recent commits (newest first)

- Merge remote-tracking branch 'origin/main'
- Add Syllabus.txt (demo syllabus the Syllabus Reader pulls from)
- /start: make Piazza a click-to-connect that opens its real login (mock)
- Messenger: reframe as general professor-emailing, trim sick-day copy
- tags on readme
- Syllabus Reader: seamless 'pull from Canvas' instead of upload
- Rebrand /start (and stray copy) to UniPilot
- Deploy: number all agents, Syllabus Reader at 4, placeholders at bottom
- Merge remote-tracking branch 'origin/main'
- Merge branch 'main' of https://github.com/panda41983/ai-hackathon-2026
- readme
- Speed up boot: drop the 7s LLM email draft from startup
- Add coming soon agents
- Fix path routing: /admin (and any route path) loads instead of bouncing to Today
- Telegram Q&A: add conversation memory + light topical context
- SYLLABUS READER
- Absence agent: any message starting with 'im sick' + content drafts immediately
- Absence agent: draft immediately when the sick message already says what to do
- Admin page + refined Telegram flows (brief Q&A, two-step sick → send)
- flavicon

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

### aicontext/overview.md

```markdown
# Student Agent Platform

## What It Is

A platform where college students activate AI agents that handle specific parts of their academic life automatically. Each agent does one job — messaging a professor, tracking a grade, managing absences — and runs silently in the background without the student thinking about it.

What makes it different is collective intelligence. When one student's agent solves a problem — gets an answer from a professor, decodes a rubric, figures out the curve — that answer gets stored. The next student who hits the same problem gets the answer instantly. The platform gets smarter with every student who uses it.

---

## Core Screens

**Gallery**
Where students discover and activate agents. Each agent card shows its name, what it does in one sentence, and how many students at their school are currently running it. The social proof is baked into the browsing experience — most popular agents surface to the top. Clicking an agent immediately triggers the setup flow without leaving the page.

**Dashboard**
The student's personal command center. Every active agent is listed with the last action it took written in plain english. Agents that acted most recently surface to the top automatically. Students can pause or remove an agent inline without navigating to a separate settings page. The feeling is a small team of people working quietly on your behalf.

**Classes**
Every course the student is enrolled in, pulled automatically. Clicking a class shows which agents are running specifically for that class and a log of what those agents have done recently for it. Makes it easy to see which classes have coverage and which don't.

**Settings**
Handles all external connections — Canvas, email, calendar. Each connection shows a simple active or needs attention state with a one tap reconnect. Nothing buried, nothing complicated.

---

## Agent Setup Flow

Triggered from the gallery. Full screen takeover. The agent introduces itself in first person — what it does, what it needs, why it needs it. One question or permission at a time. No forms. Feels like a text conversation. Ends with a single activate button. A student between classes should be able to go from discovery to active agent in under two minutes.

---

## Frontend Direction

The product should feel like something running quietly in the background of your college life. Simple and focused. A student should be able to pick it up instantly without being shown how to use it.

**Principles**
- Simple and immediately understandable. A student checking this between classes should never feel confused about what they are looking at.
- Agent activity is always written in plain english — never system language or technical jargon.
- Collective intelligence numbers are always visible. Students should feel like part of something bigger than their own account.
- Nothing is buried. The things students need are always one tap away.

**Feel**
- Calm. The dashboard should make a student feel lik
[truncated — 274 more characters]
```

### package.json

```
{
  "name": "unipilot",
  "version": "0.1.0",
  "private": true,
  "description": "UniPilot — college student AI agent platform. Vanilla frontend (public/) served by a Node/Express + SQLite backend (server/) that powers the agents.",
  "scripts": {
    "dev": "npm run dev --prefix server",
    "build": "npm run build --prefix server",
    "start": "npm run start --prefix server",
    "seed": "npm run seed --prefix server",
    "install:all": "npm install && npm install --prefix server"
  }
}

```

### server/package.json

```
{
  "name": "student-organizer-server",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc -p tsconfig.json && node -e \"require('fs').copyFileSync('src/db/schema.sql','dist/db/schema.sql')\"",
    "start": "node dist/index.js",
    "seed": "tsx src/db/seed.ts"
  },
  "dependencies": {
    "@anthropic-ai/sdk": "^0.32.1",
    "cors": "^2.8.5",
    "dayjs": "^1.11.13",
    "express": "^4.21.2",
    "googleapis": "^173.0.0",
    "mammoth": "^1.8.0",
    "multer": "^2.2.0",
    "nanoid": "^5.0.9",
    "pdf-parse": "^1.1.1",
    "rrule": "^2.8.1",
    "zod": "^3.24.1"
  },
  "devDependencies": {
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/multer": "^1.4.12",
    "@types/node": "^22.10.5",
    "tsx": "^4.19.2",
    "typescript": "^5.7.3"
  }
}

```

### client/.vite/deps/package.json

```
{
  "type": "module"
}

```

### server/src/index.ts

```typescript
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import express from "express";
import cors from "cors";

// Minimal .env loader (avoids adding the dotenv dependency).
function loadEnv() {
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
  const envPath = path.join(__dirname, "..", "..", ".env");
  if (!fs.existsSync(envPath)) return;
  for (const line of fs.readFileSync(envPath, "utf8").split("\n")) {
    const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
    if (m && !(m[1] in process.env)) process.env[m[1]] = m[2].replace(/^["']|["']$/g, "");
  }
}
loadEnv();

const { api } = await import("./http/routes.js");

const app = express();
app.use(cors());
app.use(express.json({ limit: "2mb" }));
app.use("/api", api);
app.get("/api/health", (_req, res) => res.json({ ok: true }));

// Serve the vanilla "UniPilot" frontend (single origin — no CORS/proxy needed in dev or prod).
// Cache-Control "no-cache" = the browser revalidates each load (304 when unchanged), so edits to
// app.js/styles.css show up immediately without a hard refresh while staying efficient.
const ROOT = path.dirname(fileURLToPath(import.meta.url));
const PUBLIC_DIR = path.join(ROOT, "..", "..", "public");
app.use(express.static(PUBLIC_DIR, { etag: true, setHeaders: (res) => res.setHeader("Cache-Control", "no-cache") }));
// SPA fallback: any non-API route serves the app shell (hash-router handles the rest).
app.get(/^(?!\/api).*/, (_req, res) => {
  res.setHeader("Cache-Control", "no-cache");
  res.sendFile(path.join(PUBLIC_DIR, "index.html"));
});

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

  // Hardcoded Canvas (env creds): ensure an account exists and import courses once on boot so the
  // app has real data without the in-app connect step. Idempotent — only imports if not already done.
  const { currentUser } = await import("./http/context.js");
  const { canvasConnected, canvasImport } = await import("./integrations/canvas.js");
  const { Courses } = await import("./db/repo.js");
  const u = currentUser();
  if (canvasConnected(u.id) && !Courses.listByUser(u.id).some((c) => c.source === "canvas")) {
    try {
      const r = await canvasImport(u.id);
      console.log(`Canvas import: ${r.courses} courses, ${r.assignments} assignments, ${r.grades} grades`);
    } catch (e: any) {
      console.warn("Canvas import on boot failed:", e?.message || e);
    }
  }

  // Background agents: Telegram channel (brief push + commands) and the daily-brief scheduler.
  const { startTelegram } = await import("./integrations/telegram.js");
  const { handleTelegram } = await import("./telegram-router.js");
  const { startScheduler } = await import("./scheduler.js");
  startTelegram(handleTelegram);
  startScheduler();
});

```

### server/src/db/index.ts

```typescript
// SQLite connection + schema bootstrap.

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { Db } from "./sqlite.js";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DB_PATH = process.env.DB_PATH || path.join(__dirname, "..", "..", "data.db");
const SCHEMA_PATH = path.join(__dirname, "schema.sql");

export const db = new Db(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");

export function initSchema(): void {
  const schema = fs.readFileSync(SCHEMA_PATH, "utf8");
  db.exec(schema);
  migrate();
}

// Idempotent column additions for databases created before a schema change.
// SQLite's CREATE TABLE IF NOT EXISTS won't alter an existing table, so add missing columns here.
function migrate(): void {
  const cols = new Set(
    (db.prepare("PRAGMA table_info(calendar_events)").all() as Array<{ name: string }>).map((c) => c.name)
  );
  if (!cols.has("description")) db.exec("ALTER TABLE calendar_events ADD COLUMN description TEXT");
  if (!cols.has("color")) db.exec("ALTER TABLE calendar_events ADD COLUMN color TEXT");
  if (!cols.has("courseId")) db.exec("ALTER TABLE calendar_events ADD COLUMN courseId TEXT");
}

initSchema();

```

### server/src/integrations/index.ts

```typescript
// Integration interfaces — spec §0, §9 (graceful degradation).
// Each external system is defined behind an interface with a manual-fallback stub. When real
// credentials exist, swap the stub for a live client; the agents/routes never call vendors directly.
// `available()` lets the UI show connection state and degrade without breaking the rest of the app.

import type { CalendarEvent, RateMyProfData } from "../types.js";
import { canvasConnected } from "./canvas.js";
import {
  googleConnected,
  googleConfigured,
  pushCalendarEvent,
  removeCalendarEvent,
  sendGmail,
  saveGmailDraft,
} from "./google.js";
import { edConfigured } from "./ed.js";
import { telegramConfigured, telegramLinked } from "./telegram.js";

export interface IntegrationStatus {
  name: string;
  connected: boolean;
  detail: string;
}

// ---- Canvas LMS (spec §0 accelerator) — live, token-based; see ./canvas.ts ----
// Connection state is per-user (stored token) with an env-var fallback for a server-wide token.
export const canvas = {
  available: (userId?: string) => (userId ? canvasConnected(userId) : Boolean(process.env.CANVAS_TOKEN)),
  status: (userId?: string): IntegrationStatus => {
    const connected = userId ? canvasConnected(userId) : Boolean(process.env.CANVAS_TOKEN);
    return {
      name: "Canvas",
      connected,
      detail: connected
        ? "Connected — your courses, assignments, and grades sync from Canvas."
        : "Not connected — add your Canvas link and access token to import your courses automatically.",
    };
  },
};

// ---- Google Calendar (spec §3, §8) ----
export interface CalendarSync {
  available(): boolean;
  status(): IntegrationStatus;
  /** Push a locally-created event to the external calendar; returns the external id. */
  push(event: CalendarEvent): Promise<{ externalCalendarId: string }>;
  remove(externalCalendarId: string): Promise<void>;
}

export const googleCalendar: CalendarSync = {
  available: () => googleConnected(),
  status: () => ({
    name: "Google Calendar",
    connected: googleConnected(),
    detail: googleConnected()
      ? "Connected — confirmed events sync to your Google Calendar."
      : googleConfigured()
        ? "Not connected — click Connect to authorize Google Calendar (events stay local until then)."
        : "Not configured — add GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to .env, then Connect.",
  }),
  async push(event) {
    const externalCalendarId = await pushCalendarEvent(event);
    return { externalCalendarId };
  },
  async remove(externalCalendarId) {
    await removeCalendarEvent(externalCalendarId);
  },
};

// ---- Gmail (spec §4 — DRAFT ONLY, never auto-send) ----
export interface GmailIntegration {
  available(): boolean;
  status(): IntegrationStatus;
  /** Saves a Gmail draft (never sends). Returns the draft id. */
  saveDraft(args: { to: string; subject: string; body: string }): Promise<{ draftId: string }>;
  /** Sends an email (only after explicit user confirmation — spec §9). Returns the message id. */
  sendEmail(args: { to: string; subject: string; body: string }): Promise<{ messageId: string }>;
  /** Read-scope search for the FAQ corpus (spec §5). */
  searchMessages(query: string): Promise<{ title: string; content: string; url: string }[]>;
}

export const gmail: GmailIntegration = {
  available: () => googleConnected(),
  status: () => ({
    name: "Gmail",
    connected: googleConnected(),
    detail: googleConnected()
      ? "Connected — save drafts or send (sending always requires your confirmation — spec §9)."
      : googleConfigured()
        ? "Not connected — click Connect to authorize Gmail (copy/paste drafts until then)."
        : "Not configured — add GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET to .env, then Connect.",
  }),
  async saveDraft(args) {
    const draftId = await saveGmailDraft(args);
    return { draftId };
  },
  async sendEmail(args) {
    const messageId = await sendGmail(args);
    return { messageId };
  },
  async searchMessages() {
    return []; // graceful: no email corpus available
  },
};

// ---- RateMyProfessor (spec §6 — third-party, unofficial, best-effort) ----
export interface RmpIntegration {
  available(): boolean;
  lookup(instructorName: string, school?: string | null): Promise<RateMyProfData>;
}

export const rateMyProf: RmpIntegration = {
  available: () => Boolean(process.env.RMP_ENABLED),
  async lookup(): Promise<RateMyProfData> {
    // No sanctioned public API (spec §8). Degrade gracefully with a clearly-labeled "not found".
    return {
      rating: null,
      difficulty: null,
      wouldTakeAgain: null,
      summary: null,
      url: null,
      found: false,
      source: "ratemyprofessors",
    };
  },
};

// ---- Reddit / Piazza (spec §5, §8 — best-effort, ToS-respecting) ----
export interface ExternalQnaSource {
  name: "reddit" | "piazza";
  available(): boolean;
  status(): IntegrationStatus;
  fetch(query: string): Promise<{ title: string; content: string; url: string }[]>;
}

export const reddit: ExternalQnaSource = {
  name: "reddit",
  available: () => Boolean(process.env.REDDIT_TOKEN),
  status: () => ({
    name: "Reddit",
    connected: Boolean(process.env.REDDIT_TOKEN),
    detail: "Best-effort, ToS/rate-limit respecting. Manual import otherwise (spec §8).",
  }),
  async fetch() {
    return [];
  },
};

export const piazza: ExternalQnaSource = {
  name: "piazza",
  available: () => false, // no official public API (spec §5, §8)
  status: () => ({
    name: "Piazza",
    connected: false,
    detail: "No official public API — use manual import of Q&A exports (spec §5, §8).",
  }),
  async fetch() {
    return [];
  },
};

// ---- Ed Discussion (spec §5 — live, read-only; see ./ed.ts) ----
export const ed = {
  available: () => edConfigured(),
  status: (): IntegrationStatus => ({
    name: "Ed Discussion",
    connected: edConfigured(),
    detail: edConfigured()
      ? "Connected — recent and pinned threads from your active Ed co
[truncated — 1136 more characters]
```

### public/index.html

```html
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
  <title>UniPilot</title>
  <link rel="icon" type="image/svg+xml" href="logo.svg?v=3" />
  <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=Fraunces:opsz,wght@9..144,360;9..144,440;9..144,560&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
  <link rel="stylesheet" href="styles.css?v=6" />
</head>
<body>
  <!-- Top bar: wordmark + the four destinations. Calm, editorial. -->
  <header class="topbar">
    <a class="wordmark" href="#/today">
      <img class="wordmark-mark" src="logo.svg" alt="" width="26" height="26" />
      <span class="wordmark-text">Uni<span class="wordmark-accent">Pilot</span></span>
    </a>
    <nav class="tabs" id="tabs">
      <a href="#/today" data-tab="today">Today</a>
      <a href="#/agents" data-tab="agents">Agents</a>
      <a href="#/deploy" data-tab="deploy">Deploy</a>
      <a href="#/classes" data-tab="classes">Classes</a>
      <a href="#/you" data-tab="you">You</a>
    </nav>
  </header>

  <!-- Router renders into here. -->
  <main class="stage" id="stage"></main>

  <!-- Full-screen takeover for the agent setup flow. Lives outside the tabs. -->
  <div class="takeover" id="takeover" hidden></div>

  <!-- Lightweight modal layer (email review, etc.). -->
  <div class="modal-layer" id="modal" hidden></div>

  <!-- Toasts. -->
  <div class="toast-wrap" id="toasts"></div>

  <script src="data.js?v=6"></script>
  <script src="api.js?v=6"></script>
  <script src="app.js?v=6"></script>
</body>
</html>

```

### public/data.js

```javascript
/*
 * data.js — product config + live state for the UniPilot frontend.
 *
 * The AGENTS list below is the product layer: three friendly faces (Briefer, Scheduler, Messenger)
 * over the backend's real engines (daily brief, add-to-calendar, draft-only email). Everything else
 * here is a LIVE container — empty at load, hydrated from the backend by api.js `bootstrap()`.
 * Nothing in this file is mock data anymore.
 */

// ---- The agents (gallery). Each maps to a real backend engine. ----
//   intro    – first-person lines shown at the top of the setup takeover
//   steps    – one question per screen; {prompt, options[]}
//   sample   – the first plain-english activity line it posts once activated
//   review   – true if this agent produces things you must approve (side-effects)
//   engine   – which backend engine powers it
const AGENTS = [
  {
    id: "briefer",
    name: "The Briefer",
    engine: "brief",
    tagline: "Every morning, I text your phone what matters today.",
    students: 1043,
    intro: [
      "Good to meet you. Each morning I text your phone the short version of your day — what's due, what moved, what not to forget.",
      "Thirty seconds and you're caught up, before you even open your laptop.",
    ],
    steps: [
      { prompt: "When should I text you?", options: ["7:00 AM", "8:00 AM", "9:00 AM"] },
      { prompt: "What should I include?", options: ["Due dates + changes", "Everything"] },
    ],
    sample: "Texted your morning brief — pulled straight from your courses.",
    review: false,
  },
  {
    id: "scheduler",
    name: "The Scheduler",
    engine: "calendar",
    tagline: "I scan Canvas and fill your Google Calendar with everything due.",
    students: 869,
    intro: [
      "Hey. The moment you deploy me, I scan your Canvas — every assignment, every due date —",
      "and drop it all onto your Google Calendar in seconds. No more copying deadlines by hand.",
    ],
    steps: [
      { prompt: "Which calendar should I fill?", options: ["Google Calendar"] },
      { prompt: "How far ahead?", options: ["Everything", "This term"] },
    ],
    sample: "Scanned Canvas and filled your Google Calendar with every due date.",
    review: false,
  },
  {
    id: "messenger",
    name: "The Messenger",
    engine: "email",
    tagline: "I email your professors for you — just say the word.",
    students: 912,
    intro: [
      "Hi. I handle messaging your professors so you don't have to.",
      "Text me what you need — like “I'm sick today” — I draft it, you confirm, I send it. Never without you.",
    ],
    steps: [
      { prompt: "Which classes should I cover?", options: ["All my classes", "Let me pick"] },
      { prompt: "How should I sound by default?", options: ["Casual", "Standard", "Formal"] },
    ],
    sample: "Ready — text me what to tell your professors and I'll handle it.",
    review: true,
  },
];

// =====================================================================
// LIVE containers — filled by bootstrap() in api.js from the real backend.
// =====================================================================
let USER = { name: "", first: "", email: "", school: "" };
let TODAY = { weekday: "", date: "" };
const COURSES = [];        // hydrated from GET /courses
const CONNECTIONS = [];    // hydrated from GET /status
let BRIEF = [];            // hydrated from GET /agents/brief (upcoming)
let MORNING_TEXT = { time: "", lines: [] };
let SCHEDULE = { window: [8, 19], nowHour: 12, syncedThisWeek: 0, blocks: [] };
let DRAFT = { to: "", subject: "", body: "" };

// =====================================================================
// Session state — the product layer (which agents you've activated, their
// answers, and the live activity log). Activation/config persist locally so
// the Deploy flow survives reloads; activity is seeded from real backend data.
// =====================================================================
const LS_KEY = "quietly.session.v2";
function loadSession() {
  try {
    const raw = localStorage.getItem(LS_KEY);
    if (raw) return JSON.parse(raw);
  } catch {}
  return null;
}
function saveSession() {
  try {
    localStorage.setItem(LS_KEY, JSON.stringify({ activeAgents: STATE.activeAgents, config: STATE.config, classColors: STATE.classColors }));
  } catch {}
}

const _saved = loadSession();
const STATE = {
  // Which agents are running, newest-first. Default: NONE — you deploy them yourself (demo flow).
  activeAgents: (_saved && _saved.activeAgents) || [],
  // Per-agent config: the answers chosen during setup + paused flag (filled on deploy).
  config: (_saved && _saved.config) || {},
  // Per-class box color on the Classes tab (pastel hex keyed by course id). Client-side only.
  classColors: (_saved && _saved.classColors) || {},
  // Plain-english activity log, newest-first. Seeded from real data by api.js.
  activity: [],
};

// Soft pastel palette for the Classes tab boxes + a deterministic default per course.
const CLASS_PASTELS = ["#FDE2E4", "#E2F0CB", "#DDEBFF", "#FFF1BA", "#EAD7F7", "#D0F4EA", "#FFE5D9", "#E2ECE9"];
function pastelFor(id) {
  if (STATE.classColors && STATE.classColors[id]) return STATE.classColors[id];
  let h = 0;
  for (const ch of String(id)) h = (h * 31 + ch.charCodeAt(0)) >>> 0;
  return CLASS_PASTELS[h % CLASS_PASTELS.length];
}

// ---- tiny lookup helpers ----
const agentById = (id) => AGENTS.find((a) => a.id === id);
const courseById = (id) => COURSES.find((c) => c.id === id);
const isActive = (id) => STATE.activeAgents.includes(id);
const configFor = (id) => STATE.config[id] || (STATE.config[id] = { paused: false, answers: [] });
const coveredCourses = (id) => COURSES.filter((c) => c.agents.includes(id));
const lastActionFor = (id) => STATE.activity.find((a) => a.agent === id);
// two-digit ledger numeral
const idx = (n) => String(n + 1).padStart(2, "0");

```

### public/api.js

```javascript
/*
 * api.js — the bridge between the UniPilot frontend and the real backend.
 *
 * Everything the screens show is loaded from the Node/Express + SQLite server (see /server):
 *   - the morning brief, your courses + grades, your calendar, integration status, the you profile.
 * The three product agents (Briefer / Scheduler / Messenger) are thin faces over the backend's
 * engines: brief, add-to-calendar (parse → confirm → commit), and the draft-only email writer.
 *
 * Loaded before data.js / app.js. Exposes: Backend (raw calls), bootstrap(), and a few builders
 * that map backend shapes into the shapes the existing views already render.
 */

// ---------------- tiny fetch client ----------------
async function apiFetch(method, path, body) {
  const res = await fetch(`/api${path}`, {
    method,
    headers: body !== undefined ? { "Content-Type": "application/json" } : undefined,
    body: body !== undefined ? JSON.stringify(body) : undefined,
  });
  const text = await res.text();
  const data = text ? JSON.parse(text) : null;
  if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
  return data;
}

// Multipart upload (FormData) — don't set Content-Type; the browser adds the boundary.
async function apiUpload(path, formData) {
  const res = await fetch(`/api${path}`, { method: "POST", body: formData });
  const text = await res.text();
  const data = text ? JSON.parse(text) : null;
  if (!res.ok) throw new Error((data && data.error) || `Upload failed (${res.status})`);
  return data;
}

// High-level calls, named for what they do in the product.
const Backend = {
  me: () => apiFetch("GET", "/me"),
  updateMe: (patch) => apiFetch("PATCH", "/me", patch),
  deleteData: () => apiFetch("DELETE", "/me/data"),
  status: () => apiFetch("GET", "/status"),

  courses: () => apiFetch("GET", "/courses"),
  course: (id) => apiFetch("GET", `/courses/${id}`),
  grade: (courseId) => apiFetch("GET", `/agents/grade/${courseId}`),

  brief: () => apiFetch("GET", "/agents/brief"),
  sendBriefTelegram: () => apiFetch("POST", "/agents/brief/telegram"),
  messengerHello: () => apiFetch("POST", "/agents/messenger/hello"),
  events: () => apiFetch("GET", "/events"),

  // Calendar agent (Google Calendar via service account): fill from Canvas / reset to empty.
  gcalStatus: () => apiFetch("GET", "/integrations/gcal"),
  calendarFill: () => apiFetch("POST", "/agents/calendar/fill"),
  calendarClear: () => apiFetch("POST", "/agents/calendar/clear"),
  removeEvent: (id) => apiFetch("DELETE", `/events/${id}`),

  // Add-to-calendar: parse natural language → preview (with conflicts) → commit (the only write).
  // commitEvent takes the (possibly user-edited) event incl. color/courseId/description.
  parseEvent: (text) => apiFetch("POST", "/agents/calendar/parse", { text }),
  commitEvent: (ev) =>
    apiFetch("POST", "/agents/calendar/commit", {
      title: ev.title,
      start: ev.start,
      end: ev.end,
      location: ev.location ?? null,
      description: ev.description ?? null,
      color: ev.color ?? null,
      courseId: ev.courseId ?? null,
      recurrence: ev.recurrence ?? null,
    }),
  updateEvent: (id, patch) => apiFetch("PATCH", `/events/${id}`, patch),

  // Generate-from-course: preview candidate events (classes/exams/due) from imported data, then commit.
  generateCourseEvents: (opts) => apiFetch("POST", "/agents/calendar/generate", opts),
  commitGeneratedEvents: (candidates) => apiFetch("POST", "/agents/calendar/generate/commit", { candidates }),

  // Email: draft only — the backend never sends. save-draft pushes to Gmail drafts if connected.
  emailTemplates: () => apiFetch("GET", "/agents/email/templates"),
  draftEmail: (req) => apiFetch("POST", "/agents/email/draft", req),
  saveEmailDraft: (d) => apiFetch("POST", "/agents/email/save-draft", d),

  // Canvas: connect (validates the token), then import pulls courses/assignments/grades into the DB.
  canvasConnect: (baseUrl, token) => apiFetch("POST", "/integrations/canvas/connect", { baseUrl, token }),
  canvasImport: () => apiFetch("POST", "/integrations/canvas/import"),
  canvasDisconnect: () => apiFetch("POST", "/integrations/canvas/disconnect"),

  // Google (Gmail + Calendar): OAuth consent runs via a full-page redirect to /api/auth/google.
  googleStatus: () => apiFetch("GET", "/integrations/google"),
  googleDisconnect: () => apiFetch("POST", "/integrations/google/disconnect"),
  sendEmail: (d) => apiFetch("POST", "/agents/email/send", d),

  // Ed Discussion: cached recent/pinned threads (auto-fetches once); refresh pulls live from Ed.
  ed: () => apiFetch("GET", "/ed"),
  edRefresh: () => apiFetch("POST", "/ed/refresh"),

  // Course materials + tutor: upload docs (pdf/html/txt/docx), list/remove, and chat (RAG).
  courseDocs: (courseId) => apiFetch("GET", `/courses/${courseId}/docs`),
  uploadCourseDoc: (courseId, formData) => apiUpload(`/courses/${courseId}/docs`, formData),
  deleteCourseDoc: (courseId, docId) => apiFetch("DELETE", `/courses/${courseId}/docs/${docId}`),
  courseChat: (courseId, query, history) => apiFetch("POST", `/courses/${courseId}/chat`, { query, history }),

  // Syllabus Reader (Deploy page): upload a syllabus (file or pasted text) → structured summary,
  // then chat about the class grounded in that syllabus text.
  syllabusParse: (formData) => apiUpload("/agents/syllabus/parse", formData),
  syllabusFromCanvas: () => apiFetch("POST", "/agents/syllabus/from-canvas"),
  syllabusChat: (text, query, history) => apiFetch("POST", "/agents/syllabus/chat", { text, query, history }),
};

// ---------------- mapping helpers (backend shapes → view shapes) ----------------
const DOW = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];

// "10:00" → "10 AM", "14:30" → "2:30 PM"
function fmtClock(hhmm) {
  const [h, m] = String(hhmm).split(":").map(Number);
  const ampm = h >= 12 ? "PM" : "AM";
  const h12 = ((h + 11) % 12) + 1;
  return m ? `${h12}:${String(m).padStart(2, "0")} 
[truncated — 7850 more characters]
```

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