An AI-powered mail client where the assistant controls the UI — it composes and sends email, searches and filters your inbox, opens messages, and replies — all driven by natural language. Not a chatbot: when you say "send an email to John," the compose form opens and visibly fills in; when you say "show unread from this week," the inbox list updates.
Built with Next.js + TypeScript, CopilotKit for the agent↔UI layer, the Gmail API for real mail, and a self-hosted Ollama model as the LLM.
⚠️ Runs entirely locally against real Gmail. No data leaves your machine except calls to Google (Gmail) and your own Ollama host.
A short screen recording of the assistant driving the UI. It walks through:
- Compose & send via natural language — "Send an email to … with subject … and body …" → the compose form opens, fields visibly fill in, and a confirmation card gates the send.
- Search updates the main UI — "Show me unread emails from this week" → the inbox list itself repopulates (plus email-card previews in the chat).
- Navigate & open — "Open the latest email from …" → that message opens in the reading pane.
- Context awareness — while reading an email, "reply to this" → a reply is pre-filled with the right recipient and quoted text.
- Real-time sync — a new email lands in the inbox with no manual refresh.
| Area | What it does |
|---|---|
| Mail client | Inbox & Sent lists, email detail (sandboxed HTML render), compose & send — all against real Gmail |
| Assistant — compose | "Send an email to john@acme.com about lunch" → opens compose, types the fields in, asks you to confirm, then sends |
| Assistant — search/filter | "Show unread from this week" / "emails from Sarah about the update" → the main list updates; matches also render as cards in the chat |
| Assistant — navigate/open | "Open the latest email from David" → opens that message in the reading pane |
| Context awareness | The assistant knows the open email, current folder, compose draft, and visible list — so "reply to this" just works |
| Filters | Sender, keyword, unread, date range, relative windows — via UI controls and natural language |
| Real-time sync | New mail appears in the inbox with no manual refresh (SSE + Gmail History polling) |
| Extras | Reply/forward, human-in-the-loop send confirmation, rich email-card previews in chat, thread/conversation view, dark mode, unit tests |
Clean separation between the three concerns this app has to keep apart — mail service, UI, and AI — inside one Next.js app.
Browser (React) Next.js server (route handlers)
┌───────────────────────────┐ ┌──────────────────────────────┐
│ CopilotSidebar (chat) │ │ /api/auth/* Google OAuth │
│ useMailCopilot (actions + │ HTTP/ │ /api/mail/* Gmail REST │
│ readable context) ──────┼─ SSE ──► │ /api/sync/stream SSE sync │
│ Zustand store ◄──────────┤ │ /api/copilotkit LLM runtime│
│ Mail UI (list/detail/…) │ └───────┬───────────┬──────────┘
└───────────────────────────┘ │ │
lib/mail (Gmail) Ollama (/v1)
- Mail service —
src/lib/mail/*: a provider-agnostic domain model with a Gmail service (gmail-service.ts), a structured-filter → Gmail-query compiler (query.ts), and a message parser (parse.ts). Nothing above this layer knows Gmail's raw message shape. - UI —
src/components/*render from a single Zustand store (src/store/appStore.ts), which is the one source of truth for view, selection, compose draft, filters, and results. - AI —
src/hooks/useMailCopilot.tsxregisters CopilotKit actions (each handler mutates the store, so the UI visibly reacts) and readables (the live UI state the model can see). The/api/copilotkitruntime routes the LLM to Ollama.
Why the assistant "paints" the UI: every assistant action is a thin wrapper
over a store action. searchEmails writes results into the store → the list
re-renders. sendEmail opens the compose modal and streams characters into the
fields. Because the store is shared, the AI and the human manipulate the exact
same state.
- Next.js 16 (App Router) + TypeScript + Tailwind CSS v4
- CopilotKit (
react-core,react-ui,runtime) — agent↔UI + chat sidebar - Ollama via its OpenAI-compatible API (CopilotKit
OpenAIAdapter) - googleapis (Gmail) + iron-session (encrypted cookie tokens)
- Zustand (state), Zod (request validation), Vitest (tests)
- Node.js 20+ and npm
- A Google account (personal Gmail is fine)
- A running Ollama instance with a tool-capable model pulled
(
qwen2.5,llama3.1,llama3.3, ormistral-nemo)
- Create/select a project at https://console.cloud.google.com.
- Enable the Gmail API (APIs & Services → Library → Gmail API → Enable).
- OAuth consent screen → Audience: External → add your Gmail address under Test users. Keep the app in testing (do not publish; our scopes are "restricted" and publishing would require verification).
- Credentials → Create credentials → OAuth client ID → Web application.
Add an authorized redirect URI:
http://localhost:4321/api/auth/google/callback - Copy the Client ID and Client secret.
Scopes used: gmail.readonly, gmail.send, gmail.modify, plus openid email profile.
cp .env.example .env.localFill in .env.local:
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
GOOGLE_REDIRECT_URI=http://localhost:4321/api/auth/google/callback
SESSION_SECRET= # openssl rand -base64 32
OLLAMA_BASE_URL=http://your-ollama-host:11434
OLLAMA_MODEL=qwen2.5npm install
npm run dev -- -p 4321 # http://localhost:4321Sign in with Google, then try the assistant:
- "Show me unread emails from the last 7 days"
- "Open the latest email from "
- While reading one: "reply to this and say I'll join the call"
- "Send an email to with subject Hello and body Testing the copilot"
npm run dev # dev server
npm run build # production build
npm test # unit tests (Vitest)
npm run typecheck # tsc --noEmitBuilding: stop the dev server first (a concurrent build and dev server contend for the
.nextdir and can OOM the build worker). If the build worker still runs out of memory, raise Node's heap:NODE_OPTIONS=--max-old-space-size=6144 npm run build.
- Full-stack Next.js instead of a separate backend. One deployable, one
language; Next route handlers give a real backend (OAuth, Gmail, SSE, LLM
runtime) without a second service. Clean separation is enforced by module
boundaries (
lib/mailvscomponentsvshooks/AI), not by process split. - CopilotKit over a hand-rolled agent loop. It provides the exact
primitives this needs — frontend actions (
useCopilotAction), readable context (useCopilotReadable), generative UI in chat, and human-in-the-loop — and is the modern standard (it authored the AG-UI protocol). - Self-hosted Ollama. No hosted-LLM key required. Because small local models
are weaker at tool-calling, the action set is deliberately tight, parameters
are described precisely, tool calls run sequentially
(
disableParallelToolCalls), and a firm system prompt steers behavior. - Sandboxed iframe for HTML email. Untrusted email markup renders in an
iframe with
sandbox=""(no scripts, no app access) — safer than injecting HTML, no extra sanitizer dependency. - Real-time via polling, not Pub/Sub. For a local demo, an SSE endpoint that
polls the Gmail History API needs no public webhook. The production path
(Gmail
watch→ Pub/Sub → webhook) is a drop-in replacement behind the same client contract. - Tokens in an encrypted, http-only cookie (
iron-session) — never exposed to client JS; all Gmail/LLM calls happen server-side.
src/
app/
api/{auth,mail,sync,copilotkit}/… # backend route handlers
layout.tsx, page.tsx, globals.css
lib/
mail/{gmail-service,query,parse,types}.ts # mail service
google/{oauth,session,types}.ts # auth
ai/{instructions,typewriter}.ts # assistant helpers
api-client.ts, compose-helpers.ts, format.ts, cn.ts
store/appStore.ts # single source of truth
hooks/{useMailCopilot,useMailSync}.tsx
components/… # UI + assistant components
MIT. Not affiliated with or endorsed by Google; Gmail is a trademark of Google LLC.