# Project export: sched

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: TreeHacks 2026
- Tagline: meet the right people at the right time with sched: your ai executive assistant that lives in your inbox
- Devpost: https://devpost.com/software/sched-o6jbsp
- GitHub: https://github.com/not-aryan/sched
- Demo: https://sched-th.vercel.app/
- Video: https://www.youtube.com/embed/ftZlLKx2eYA?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Team: 4 GitHub contributor(s) — Aryan Jain (19 commits), Vineet Sharma (13 commits), Sanjith Udupa (8 commits), Cursor (5 commits)

## Devpost submission (written by the team)

### Inspiration

Scheduling meetings and managing a busy schedule as students with a lot of commitments is hard. We each manage 3+ calendars, with events ranging from classes, club and work meetings, and dinners with friends. Keeping track of what you're doing and who you're meeting is something that can easily slip through the cracks, and when people reach out over email, it gets even harder navigating back and forth between calendars, mail clients, and your own personal preferences.

### What it does

To address this, we built sched, an AI agent that lives in your email and intelligently schedules meetings based on your calendar and what it knows about you and the person requesting to meet. When someone reaches out to you asking to meet, you reply and cc our sched agent, which will then spin up and effectively become your personal executive assistant: intelligently creating an event and invite depending on the urgency of the request (based on both who sends it and the content of the initial email) and your schedule + what sched has learned over time about you (morning/afternoon meeting preference, spacing preferences between meetings, time zones, etc.) and the other user. Sched responds naturally (from its own email) to the inviter with some proposed times, and intelligently handles multi-turn conversations in the thread to come to a time that works for both of you (erring on the side of giving you times that you prefer). If the inviter has a sched account for themself, then sched already knows their preferences and thus performs the entire back-and-forth internally, with no human-to-human conversation needed because your personal agents handle it themselves. Depending on the context of the meeting, sched will either create a Google Meet link for the event or just make a regular calendar event. Sched resides entirely in your email, including the onboarding! Just send an email to sched@agentmail.to and it'll walk you through connecting your calendar and providing any initial preference information (which gets learned additionally over time). While the primary functionality of sched is fully in the email inbox, we've built two web interfaces to make sched even more powerful. The first is an interactive chat and calendar view, where Sched can easily ask you for your permission or opinion on an event it might be scheduling, which it can then learn from and not ask you about in the future. The second is more about the broader implication of the future of agent-to-agent communication for the sake of human meetings: the connection graph. The 3D graph visualizer shows you all the users in your organization and draws weighted edges between people who have met and introduces you to their mutuals, allowing you to directly email them and schedule a time to meet!

### How we built it

To build the mail agent, we used the AgentMail API to handle reading incoming emails to a fixed inbox and send the responses to each person. We developed the agent architecture around the Claude Agent SDK and added custom tools to link into the AgentMail sending functions + read/write access to user Google and Outlook calendars via Composio connectors. Our backend is built with Hono in TypeScript and NodeJS, with endpoints for general database access (we used Supabase and the Prisma ORM) as well as webhooks for AgentMail. The frontend visualizer is built with NextJS using the ReactForceGraph library to make the 3d simulated connection graph, with a custom Three.JS shader for the glowing effects.

### Challenges we ran into

A major challenge we ran into was latency with the API. For testing purposes, we created a proxy tunnel with ngrok, but we faced major issues with the speed of email reception and sending via AgentMail. We unfortunately don't have much control with how fast the email servers send or recieve emails, but we addressed this by tightening up our agent loop as much as possible to keep the latency that we could control as minimal as possible.

### Accomplishments we're proud of

We're very proud of having our agent be intelligent to handle multi-turn conversation with the requester and using the tone as well as user preferences to allow scheduling based on inherent priority of certain events. This is something that differentiates our product from most other calendar assistants because this inherent understanding of what each human involved needs for scheduling is something that is not easy to do unless it's your main focus, which it was for us. We're also very proud of how our agent is proactive at learning its owners preferences correctly. When the agent asks its owner for permission to schedule time, it'll learn the preference and won't ask again unless it makes sense given the context. Especially when the agent is interacting with another user who has their own sched agent, the competing preferences result in the best compromise for both parties.

### What we learned

We learned how important maintaining context in multi-turn conversations is, especially when the agents really have motivations to give their owners the preferred time, we see truly smart scheduling behavior where the agents act correctly on the owner's behalf. Additionally, we learned how powerful using connectors is: using Composio to access Google Calendar rather than just using the Google Calendar API or MCP by itself allowed enabling Outlook access to be a 5 minute change rather than a major one.

### What's next

Sched currently resides fully in email, which is where most (but not all) meeting scheduling occurs. In order to make Sched a truly useful personal assistant, it should be able to connect to other messaging tools like Slack, iMessage, and more.

## README (from the GitHub repository)

# Sched

AI scheduling agent. CC it into an email thread and it coordinates a meeting — checks your calendar, proposes times, and books when confirmed.

---

## How It Works

1. You're emailing someone about meeting up
2. CC `sched-agent@agentmail.to` into the thread
3. The agent reads the conversation, checks **your** Google Calendar, and proposes 2-3 times
4. The other party replies to pick a time
5. The agent creates the calendar event and confirms to everyone

```
You                  Guest                Agent
 │                     │                    │
 │── email ───────────>│                    │
 │<──── reply ─────────│                    │
 │── reply + CC agent─>│───────────────────>│
 │                     │                    │── check calendar
 │                     │<── propose times ──│
 │                     │── "Tuesday works"─>│
 │                     │                    │── create event
 │<── "Booked!" ───────│<── "Booked!" ──────│
```

The agent only manages **your** calendar. Guests don't need accounts.

### Onboarding

Email the agent directly for the first time and it sends a Google Calendar OAuth link. Once connected, it can manage your calendar.

---

## Tech Stack

- **Runtime**: Node.js + TypeScript (ESM)
- **Server**: [Hono](https://hono.dev)
- **AI**: Claude Haiku 4.5 via [`@anthropic-ai/claude-agent-sdk`](https://www.npmjs.com/package/@anthropic-ai/claude-agent-sdk)
- **Calendar**: Google Calendar via [Composio](https://composio.dev)
- **Email**: [AgentMail](https://agentmail.to)
- **Database**: PostgreSQL (Supabase) via [Prisma](https://prisma.io)
- **Hosting**: [Railway](https://railway.com)

---

## Project Structure

| File                    | Purpose                                                        |
| ----------------------- | -------------------------------------------------------------- |
| `src/index.ts`          | Hono server, webhook handler, routing logic                    |
| `src/agent.ts`          | Claude agent — system prompt, email processing, tool wiring    |
| `src/tools.ts`          | MCP mail tools (`reply`, `save_preferences`)                   |
| `src/composio.ts`       | Google Calendar integration via Composio                       |
| `src/mail.ts`           | AgentMail client wrapper                                       |
| `src/onboarding.ts`     | Onboarding flow — welcome email + OAuth link                   |
| `src/onboarding-tools.ts` | Onboarding-specific agent tools                              |
| `src/utils.ts`          | Email parsing, participant classification                      |
| `src/types.ts`          | Shared types and thread formatting                             |
| `src/db.ts`             | Prisma client                                                  |

---

## Setup

### 1. Install

```bash
npm install
```

### 2. Environment Variables

Create a `.env` file:

| Variable                  | Required | Description                                                  |
| ------------------------- | -------- | ------------------------------------------------------------ |
| `AGENTMAIL_API_KEY`       | Yes      | From [AgentMail](https://agentmail.to)                       |
| `COMPOSIO_API_KEY`        | Yes      | From [Composio](https://composio.dev)                        |
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes      | Claude Code OAuth token (or `ANTHROPIC_API_KEY`)             |
| `INBOX_USERNAME`          | Yes      | Inbox name (e.g. `sched-agent` -> `sched-agent@agentmail.to`)|
| `WEBHOOK_URL`             | Yes      | Webhook URL (ngrok for local, Railway URL for prod)          |
| `DATABASE_URL`            | Yes      | PostgreSQL connection string (pooled)                        |
| `DIRECT_URL`              | Yes      | PostgreSQL direct connection string                          |
| `PORT`                    | No       | Default `3000`                                               |

### 3. Local Development

```bash
# Terminal 1: expose localhost
ngrok http 3000

# Terminal 2: dev server (auto-restarts on changes)
npm run dev
```

Set `WEBHOOK_URL` in `.env` to the ngrok HTTPS URL. The server registers the inbox and webhook on startup.

---

## Deployment (Railway)

The app is deployed on Railway as a long-running Node.js service using Docker.

### Why Railway over Vercel

The Claude Agent SDK spawns a subprocess that can run for 30+ seconds per request. Vercel's serverless functions time out too quickly. Railway runs the app as a persistent server — no timeout issues.

### Dockerfile

The `Dockerfile` handles:
- **OpenSSL** for Prisma
- **Non-root user** — Claude Code refuses to run as root (security restriction)
- **Prisma client generation** at build time

### Deploy

```bash
# Install Railway CLI
npm i -g @railway/cli

# Login
railway login

# Init project (first time)
railway init

# Deploy
railway up
```

### Environment Variables

Set all env vars from the table above on Railway:

```bash
railway variables set KEY=value ...
```

Set `WEBHOOK_URL` to `https://<your-app>.up.railway.app/webhooks`.

### Public Domain

```bash
railway domain
```

---

## Scripts

| Command            | Purpose                                |
| ------------------ | -------------------------------------- |
| `npm run dev`      | Dev server with file watching          |
| `npm start`        | Production server                      |
| `npm run chat`     | CLI chat with the agent (no email)     |
| `npm run simulate` | E2E simulation with real AgentMail     |

---

## API

- `GET /` — Health check
- `POST /webhooks` — AgentMail webhook (`message.received`)
- `POST /users` — Register a new user
- `GET /oauth/callback` — Composio OAuth redirect


## Detected evidence (automated analysis)

Indexed codebase: 26 recognized source files, 113 KB.
- CSS (language) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- React (technology) — detected in the code
- SQL (language) — detected in the code
- Supabase (technology) — detected in the code
- TypeScript (language) — detected in the code
- Anthropic (technology) — claimed on Devpost, not found in the code
- AI coding agent: Cursor — evidence: commit authorship or trailers

## Codebase structure (from repository index)

### Files (37 of 37)

```
.env.example
.github/workflows/main.yml
.gitignore
Dockerfile
package.json
prisma.config.ts
prisma/schema.prisma
README.md
scripts/cli-chat.ts
scripts/simulate.ts
src/agent.ts
src/composio.ts
src/db.ts
src/index.ts
src/mail.ts
src/onboarding-tools.ts
src/onboarding.ts
src/tools.ts
src/types.ts
src/utils.ts
TREEHACKS_DEMO_SCRIPT.md
tsconfig.json
vercel.json
web/.env.example
web/components/ForceGraph3DWrapper.tsx
web/DEPLOY.md
web/lib/supabase-server.ts
web/next-env.d.ts
web/package.json
web/pages/_app.tsx
web/pages/api/graph.ts
web/pages/index.css
web/pages/index.tsx
web/scripts/seed-graph-data.js
web/scripts/supabase-schema.sql
web/tsconfig.json
web/vercel.json
```

### Dependencies

- package.json: @anthropic-ai/claude-agent-sdk@^0.2.41, @composio/claude-agent-sdk@^0.6.3, @composio/client@0.1.0-alpha.56, @composio/core@^0.6.3, @hono/node-server@^1.13.5, @prisma/client@^6.19.2, @types/node@^22.0.0, agentmail@^0.2.15, dotenv@^16.4.5, hono@^4.6.0, prisma@^6.19.2, tsx@^4.19.0, typescript@^5.6.0, zod@^4.0.0
- web/package.json: @supabase/supabase-js@^2.47.10, @types/node@^22.10.7, @types/react@^19.0.11, @types/react-dom@^19.0.4, @types/three@^0.182.0, dotenv@^16.4.5, next@^16.0.0-beta.0, react@^19.2.4, react-dom@^19.2.4, react-force-graph-3d@^1.29.1, three@^0.182.0, typescript@^5.7.3

### Recent commits (newest first)

- 30s script update
- adding zoom on node upon search
- script
- delpoyment
- Merge pull request #8 from Not-Aryan/aryan/mvp
- Merge pull request #6 from Not-Aryan/sanjith/frontend
- make gh action workflow for deployment
- Merge pull request #7 from Not-Aryan/aryan/mvp
- Merge pull request #5 from Not-Aryan/sanjith/instant-2
- fix merge conflicts erorr rsesult
- Merge branch 'aryan/mvp' into sanjith/instant-2
- add agent to agent back
- updated favicon
- outlook support
- railway deploy
- export Hono app for Vercel serverless with lazy init
- increase Vercel function timeout to 60s, pin webhook clientId
- add build script for Vercel: run prisma generate before TS compilation
- add name upon hover
- unify mailing logic: centralize thread resolution and pre-bind reply tools

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

### TREEHACKS_DEMO_SCRIPT.md

```markdown
# TreeHacks Demo Script — Part 3: Connection Graph (~30 sec)

*Use after (1) Gmail thread with the agent and (2) chat where the agent confirms the time.*

---

## Script (~30 seconds)

**[0:00 — Click “view connection graph”]**

> “Last piece: we don’t just schedule one meeting — we show you *who actually works together* in your org, at no extra cost.”

**[0:05 — Graph loads]**

> “Every node is a real person from threads where our agent was CC’d. Every edge is a connection between two people in those threads. Stronger links pull people closer — so clusters are real collaboration, not guesswork.”

**[0:15 — Search an email, select from dropdown; camera flies to node]**

> “Search anyone — we fly to them and highlight their connections. This is the view that usually costs a fortune in enterprise software. We surface it for free from the same CC. One click opens Gmail to that person — the graph is a launchpad.”

**[0:25 — Closing]**

> “So that’s Sched: the agent in the thread, the chat, and this graph — all from one CC. Infinite time saved, at no cost, plus a map of who actually works together. That’s what we’re building for TreeHacks.”

---

## Quick Reference

| When | Action | Line |
|------|--------|------|
| 0:00 | Click **“view connection graph”** | “Last piece: we show who actually works together…” |
| 0:05 | Graph animates in | “Every node is a person, every edge a connection…” |
| 0:15 | Search email, select; camera flies | “Search anyone — we fly to them… free from the same CC.” |
| 0:25 | — | “Infinite time saved, map of who works together. TreeHacks.” |

---

## One-Liner (judge in a hurry)

> “Connection graph: every node is someone in your org, every edge is ‘agent CC’d with both.’ Search anyone, see their connections, jump to Gmail — all from the same CC. Infinite time saved, free map of who works with whom.”

```

### web/DEPLOY.md

```markdown
# Deploy the web app on Vercel

Deploy this Next.js app from the **web** directory using your own Vercel project.

## 1. Fork and push

- Fork the repo on GitHub (or your Git host) from `main`.
- Clone your fork and push any changes so Vercel can access the code.

## 2. Import in Vercel

1. Go to [vercel.com](https://vercel.com) and sign in.
2. **Add New** → **Project** and import your fork.
3. **Important:** set **Root Directory** to `web`:
   - Click **Edit** next to “Root Directory”.
   - Choose `web` (or enter `web`) and confirm.
4. Set these in **Build & Development Settings** (or leave defaults; Vercel usually detects them):

   | Setting           | Value           | Notes |
   |-------------------|-----------------|--------|
   | **Install Command** | `yarn install` | Repo has `yarn.lock`; use yarn. |
   | **Build Command**  | `yarn build`   | Runs `next build` from package.json. |
   | **Output Directory** | *(leave empty)* | Next.js on Vercel uses its own output; do not set. |

## 3. Environment variables

In the Vercel project: **Settings → Environment Variables**, add:

| Name | Description |
|------|-------------|
| `NEXT_PUBLIC_SUPABASE_URL` | Supabase project URL (Supabase Dashboard → Project Settings → API) |
| `SUPABASE_SERVICE_ROLE_KEY` | Supabase service role key (same place; keep this secret) |

Add them for **Production** (and Preview if you want the same behavior for PRs).

## 4. Deploy

Click **Deploy**. Vercel will install dependencies in `web`, run `yarn build`, and deploy. Later pushes to `main` (or your default branch) will trigger new deployments.

## Optional: deploy only the web app from CLI

From the repo root:

```bash
cd web
npx vercel
```

When prompted, set the project’s root to the current directory (`web`). For a linked project, you can set the root to `web` in the Vercel dashboard so all deploys use this app.

```

### package.json

```
{
  "name": "sched",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "prisma generate",
    "start": "tsx src/index.ts",
    "chat": "tsx scripts/cli-chat.ts",
    "simulate": "tsx scripts/simulate.ts"
  },
  "dependencies": {
    "@anthropic-ai/claude-agent-sdk": "^0.2.41",
    "@composio/claude-agent-sdk": "^0.6.3",
    "@composio/client": "0.1.0-alpha.56",
    "@composio/core": "^0.6.3",
    "@hono/node-server": "^1.13.5",
    "@prisma/client": "^6.19.2",
    "agentmail": "^0.2.15",
    "dotenv": "^16.4.5",
    "hono": "^4.6.0",
    "zod": "^4.0.0"
  },
  "devDependencies": {
    "@types/node": "^22.0.0",
    "prisma": "^6.19.2",
    "tsx": "^4.19.0",
    "typescript": "^5.6.0"
  }
}

```

### Dockerfile

```
FROM node:22-slim

# Install OpenSSL (required by Prisma)
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*

# Create non-root user (Claude Code refuses to run as root)
RUN useradd -m -s /bin/bash agent

WORKDIR /app

# Copy dependency files first for better caching
COPY package.json package-lock.json* ./

# Install all dependencies (including devDependencies for tsx, prisma)
RUN npm install

# Copy source code
COPY . .

# Generate Prisma client (dummy URLs — only needed for client codegen, not connection)
RUN DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" \
    DIRECT_URL="postgresql://dummy:dummy@localhost:5432/dummy" \
    npx prisma generate

# Switch to non-root user
USER agent

EXPOSE 3000

CMD ["npm", "start"]

```

### web/package.json

```
{
  "name": "web",
  "version": "0.1.0",
  "description": "Fancy Next.js app",
  "scripts": {
    "dev": "next",
    "build": "next build",
    "start": "next start",
    "build:static": "next build && next out",
    "seed:graph": "node scripts/seed-graph-data.js"
  },
  "dependencies": {
    "@supabase/supabase-js": "^2.47.10",
    "@types/three": "^0.182.0",
    "next": "^16.0.0-beta.0",
    "react": "^19.2.4",
    "react-dom": "^19.2.4",
    "react-force-graph-3d": "^1.29.1",
    "three": "^0.182.0"
  },
  "devDependencies": {
    "dotenv": "^16.4.5",
    "@types/node": "^22.10.7",
    "@types/react": "^19.0.11",
    "@types/react-dom": "^19.0.4",
    "typescript": "^5.7.3"
  }
}

```

### src/index.ts

```typescript
import "dotenv/config";
import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { processEmail } from "./agent.js";
import { processOnboarding, startOnboarding, sendCalendarConfirmation } from "./onboarding.js";
import * as mail from "./mail.js";
import { getInboxId, extractEmail, parseSenderEmail, getParticipantEmails, classifyParticipants } from "./utils.js";
import { formatThreadHistory, type CalendarProvider, type IncomingMessage, type ResolvedContext, type ThreadMessageRaw } from "./types.js";
import prisma from "./db.js";

const app = new Hono();
const processedMessages = new Map<string, number>();
const PROCESS_TTL_MS = 5 * 60 * 1000;

function seenRecently(key: string): boolean {
  const now = Date.now();
  const last = processedMessages.get(key);
  if (last && now - last < PROCESS_TTL_MS) return true;
  processedMessages.set(key, now);
  if (processedMessages.size > 5000) {
    for (const [k, v] of processedMessages) {
      if (now - v > PROCESS_TTL_MS) processedMessages.delete(k);
    }
  }
  return false;
}

// ---------------------------------------------------------------------------
// Health
// ---------------------------------------------------------------------------

app.get("/", (c) =>
  c.json({
    status: "ok",
    service: "sched",
    webhook: "POST /webhooks",
  })
);

// ---------------------------------------------------------------------------
// User management API
// ---------------------------------------------------------------------------

/** Register a new user */
app.post("/users", async (c) => {
  const body = await c.req.json<{ email?: string; name?: string }>();
  if (!body.email) return c.json({ error: "email is required" }, 400);

  const email = body.email.trim().toLowerCase();

  const existing = await prisma.user.findUnique({ where: { email } });
  if (existing) return c.json({ error: "user already exists" }, 409);

  const user = await prisma.user.create({
    data: { email, name: body.name?.trim() || null, status: "onboarding" },
  });

  console.log("[api] created user", user.email);
  return c.json(user, 201);
});

/** List all users */
app.get("/users", async (c) => {
  const users = await prisma.user.findMany({ orderBy: { createdAt: "desc" } });
  return c.json(users);
});

/** Get a single user by email */
app.get("/users/:email", async (c) => {
  const user = await prisma.user.findUnique({
    where: { email: c.req.param("email").toLowerCase() },
  });
  if (!user) return c.json({ error: "not found" }, 404);
  return c.json(user);
});

/** Delete a user */
app.delete("/users/:email", async (c) => {
  const email = c.req.param("email").toLowerCase();
  try {
    await prisma.user.delete({ where: { email } });
    return c.json({ deleted: true });
  } catch {
    return c.json({ error: "not found" }, 404);
  }
});

// ---------------------------------------------------------------------------
// OAuth callback (auto-complete onboarding when user connects calendar)
// ---------------------------------------------------------------------------

app.get("/oauth/callback", async (c) => {
  const email = c.req.query("email")?.trim().toLowerCase();
  const error = c.req.query("error");
  const providerParam = c.req.query("provider") as CalendarProvider | undefined; // "google" | "outlook"

  if (error) {
    return c.html(
      "<!DOCTYPE html><html><body><h1>Connection failed</h1><p>Something went wrong. Please try the link from your welcome email again.</p></body></html>",
      200
    );
  }

  if (!email) {
    return c.html(
      "<!DOCTYPE html><html><body><h1>Connection complete</h1><p>Your calendar is connected. You can close this tab.</p></body></html>",
      200
    );
  }

  const user = await prisma.user.findUnique({ where: { email } });

  // Determine the calendar provider: use the query param if present, otherwise
  // fall back to whatever was already stored on the user record.
  const provider: CalendarProvider | null =
    providerParam ?? (user?.calendarProvider as CalendarProvider | null) ?? null;

  if (user?.status === "onboarding") {
    await prisma.user.update({
      where: { email },
      data: {
        status: "active",
        onboardedAt: new Date(),
        ...(provider ? { calendarProvider: provider } : {}),
      },
    });
    console.log("[oauth] auto-completed onboarding for", email, "provider:", provider ?? "unknown");

    // Fire-and-forget: send confirmation + preference questions in the onboarding thread
    sendCalendarConfirmation(email, provider).catch((err) =>
      console.error("[oauth] failed to send confirmation:", err)
    );
  } else if (user && provider && !user.calendarProvider) {
    // User already active but provider wasn't stored yet — backfill it
    await prisma.user.update({
      where: { email },
      data: { calendarProvider: provider },
    });
  }

  const calendarName = provider === "outlook" ? "Outlook Calendar" : "Google Calendar";
  return c.html(
    `<!DOCTYPE html><html><body><h1>${calendarName} connected!</h1><p>You're all set. You can close this tab and start CC'ing sched-agent@agentmail.to into your emails to schedule meetings.</p></body></html>`,
    200
  );
});

// ---------------------------------------------------------------------------
// Webhook
// ---------------------------------------------------------------------------

app.post("/webhooks", async (c) => {
  let payload: unknown;
  try {
    payload = await c.req.json();
  } catch {
    return c.json({ error: "Invalid JSON" }, 400);
  }

  const body = payload as { event_type?: string; message?: Record<string, unknown> };
  if (body.event_type !== "message.received") {
    return c.json({}, 200);
  }

  const msg = body.message as Record<string, unknown> | undefined;
  console.log("[webhook] received", body.event_type, "from:", msg?.from_, "subject:", msg?.subject);

  // Fire-and-forget: return 200 immediately so AgentMail doesn't wait.
  // Processing continues in the backgrou
[truncated — 8819 more characters]
```

### web/pages/index.tsx

```typescript
/**
 * Sched — Home page
 *
 * Landing page with a 3D force-directed connection graph. Graph data is loaded from
 * the Supabase-backed /api/graph endpoint. Users can toggle the graph, search by
 * email (with autocomplete), and click nodes to open Gmail compose in a new tab.
 */

import Head from "next/head";
import dynamic from "next/dynamic";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";

// Wrapper needed so ref reaches the graph (next/dynamic does not forward refs); enables zoom on "view connection graph"
const ForceGraph3D = dynamic(() => import("../components/ForceGraph3DWrapper"), { ssr: false });

/** Shape of the graph consumed by the 3D force graph: nodes (users) and links (connections). */
type GraphData = {
  nodes: { id: string; group: number; email: string }[];
  links: { source: string; target: string }[];
};

/**
 * Tracks the width and height of a DOM element via ResizeObserver.
 * @returns ref to attach to the element, and { width, height } state.
 */
function useSize<T extends HTMLElement>() {
  const ref = useRef<T | null>(null);
  const [size, setSize] = useState({ width: 0, height: 0 });

  useEffect(() => {
    if (!ref.current) return;
    const el = ref.current;
    const update = () =>
      setSize({
        width: Math.max(0, el.clientWidth),
        height: Math.max(0, el.clientHeight),
      });
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  return { ref, size };
}

export default function Home() {
  // --- UI state ---
  const [reveal, setReveal] = useState(false);
  const { ref, size } = useSize<HTMLDivElement>();
  const graphRef = useRef<any>(null);
  const [hovered, setHovered] = useState<string | null>(null);
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const [selectedEmail, setSelectedEmail] = useState<string | null>(null);
  const [hasSearch, setHasSearch] = useState(false);

  // --- Email search autocomplete ---
  const [searchInput, setSearchInput] = useState("");
  const [suggestionsOpen, setSuggestionsOpen] = useState(false);
  const [highlightedIndex, setHighlightedIndex] = useState(0);

  // --- Graph data (from API) ---
  const [data, setData] = useState<GraphData | null>(null);
  const [graphError, setGraphError] = useState<string | null>(null);
  const [graphLoading, setGraphLoading] = useState(true);

  // Load graph from Supabase-backed API on mount.
  useEffect(() => {
    let cancelled = false;
    setGraphLoading(true);
    setGraphError(null);
    fetch("/api/graph")
      .then((res) => {
        if (!res.ok) throw new Error(res.statusText);
        return res.json();
      })
      .then((body: GraphData) => {
        if (!cancelled) {
          setData(body);
          setGraphError(null);
        }
      })
      .catch((err) => {
        if (!cancelled) setGraphError(err instanceof Error ? err.message : "Failed to load graph");
      })
      .finally(() => {
        if (!cancelled) setGraphLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, []);

  /** Current graph payload; empty until fetch completes. */
  const graphData: GraphData = data ?? { nodes: [], links: [] };

  /** Progress of the node "reveal" animation (0..1). */
  const [progress, setProgress] = useState(0);

  // Animate progress so nodes appear gradually when graph is shown.
  useEffect(() => {
    if (graphData.nodes.length === 0) return;
    let raf = 0;
    const start = performance.now();
    const duration = 2500 + Math.random() * 1500;

    const tick = () => {
      const now = performance.now();
      const t = Math.min(1, (now - start) / duration);
      setProgress(Math.max(0.02, t));
      if (t < 1) raf = requestAnimationFrame(tick);
    };

    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, [graphData.nodes.length]);

  const visibleCount = Math.max(1, Math.floor(graphData.nodes.length * progress));
  /** Node ids that are "visible" in the reveal animation (first N by order). */
  const visibleSet = useMemo(() => {
    return new Set(graphData.nodes.slice(0, visibleCount).map((n) => n.id));
  }, [graphData.nodes, visibleCount]);

  /** Map node id -> index for alpha/reveal order. */
  const nodeIdToIndex = useMemo(() => {
    const m = new Map<string, number>();
    graphData.nodes.forEach((n, i) => m.set(n.id, i));
    return m;
  }, [graphData.nodes]);

  /** Alpha for a node in the reveal animation (0 = not yet visible, 1 = fully visible). */
  const nodeAlpha = (id?: string) => {
    if (!id) return 0;
    const idx = nodeIdToIndex.get(id);
    if (idx === undefined) return 0;
    const local = progress * graphData.nodes.length - idx;
    return Math.max(0, Math.min(1, local));
  };

  /** Node ids that are directly connected to the selected node (for highlighting). */
  const neighborSet = useMemo(() => {
    if (!selectedId) return new Set<string>();
    const set = new Set<string>();
    for (const l of graphData.links) {
      const s = typeof l.source === "string" ? l.source : (l.source as { id?: string })?.id;
      const t = typeof l.target === "string" ? l.target : (l.target as { id?: string })?.id;
      if (s === selectedId && t) set.add(t);
      if (t === selectedId && s) set.add(s);
    }
    return set;
  }, [graphData.links, selectedId]);

  /** Number of connections (links) for a node. */
  const degreeOf = (id: string) => {
    return graphData.links.filter((l) => {
      const s = typeof l.source === "string" ? l.source : (l.source as { id?: string })?.id;
      const t = typeof l.target === "string" ? l.target : (l.target as { id?: string })?.id;
      return s === id || t === id;
    }).length;
  };

  /** Resolve an email (exact match, case-insensitive) to a node id, or first node as fallback. */
  const pickNodeForEmail = (email: string) => {
    const normalized = email.trim().toLowerCase();
    const node = 
[truncated — 13589 more characters]
```

### prisma.config.ts

```typescript
import "dotenv/config";
import { defineConfig, env } from "prisma/config";

export default defineConfig({
  schema: "prisma/schema.prisma",
  migrations: {
    path: "prisma/migrations",
  },
  engine: "classic",
  datasource: {
    // Use DIRECT_URL for migrations/introspection (bypasses PgBouncer)
    url: env("DIRECT_URL"),
  },
});

```

### src/db.ts

```typescript
import { PrismaClient } from "./generated/prisma/index.js";

const prisma = new PrismaClient();

export default prisma;

```

### web/next-env.d.ts

```typescript
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.

```

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