---
name: agentic-gmail
description: >
  Build a Matagi-branded, single-user "Agentic Gmail" — a chat app that is a
  fully agentic control plane for a Gmail account. The user types in plain
  language; an LLM agent loop runs real Gmail actions (search, count, read,
  triage, label, archive, trash, draft, send, reply) rendered as live, branded
  Gmail tool cards. Use when someone wants to build, extend, restyle, or
  redeploy this app, or any Matagi internal tool that drives a connected app
  through a chat agent. Covers the design system, the auth model, the runtime
  wiring (Vercel + connection proxy + LLM completion), the JSON tool-calling
  loop, the Gmail tool registry, optional conversation history + a past-chats
  sidebar (Supabase), and deployment.
---

# Agentic Gmail

A drop-in blueprint for an **agentic control plane over a connected app**, shipped
as a dark, text-first Matagi chat tool. The reference target is Gmail, but the
shape (auth → agent loop → connection proxy → branded tool cards) is reusable for
any connected app.

The whole thing is four parts:

1. **Design** — dark Matagi theme, one warm accent, an attribution chip, a login page.
2. **Auth** — single-user, a preset password injected as an env secret, signed cookie.
3. **Runtime** — a Next.js app on Vercel that calls the Matagi API for LLM completions and for proxied Gmail calls.
4. **Agent** — a hand-rolled JSON tool-calling loop streaming branded tool cards to the UI.

You don't need any cloud accounts of your own — Matagi provisions the GitHub repo,
the Vercel deployment, the LLM access, and the Gmail proxy for you. You drive the
whole build by talking to an AI coding agent that has the Matagi MCP connected.

---

## Prerequisites (set these up first)

1. **A Gmail connection in Matagi.** In Matagi → Integrations, connect the Gmail
   account you want the app to control. (Any connected app works the same way;
   this guide uses Gmail.)
2. **The Matagi MCP connected to your coding agent** — Claude Code, Codex, or
   Cursor. This exposes the `*_provision` / `*_deploy` / `*_share_*` tools the
   build uses. With it connected, you can just ask the agent to "build Agentic
   Gmail" and it runs the steps in section 3.
3. **A password stored as a Matagi custom credential.** In Matagi → Integrations
   → custom credentials, create a secret (e.g. `APP_LOGIN_PASSWORD`) and set its
   value to the password you'll log in with. This value is injected into the
   deployment as an env var and is never typed into code or seen by the browser.

That's the entire dependency list: a Gmail connection, the Matagi MCP, and one
custom-credential password. Everything else is provisioned during the build.

Tell your agent which to use, e.g.: *"Build Agentic Gmail. Use my Gmail
connection `<account>`, allowlist the login email `<email>`, and use the custom
credential `APP_LOGIN_PASSWORD` as the password."*

---

## 1. Design system

Dark, calm, dense. One accent (`#E43D12`). Copy the tokens verbatim.

`tailwind.config.ts`:

```ts
colors: {
  matagi: '#E43D12',                                                   // accent
  ink: { 900:'#171717', 800:'#1f1f1f', 700:'#262626', 600:'#333333' }, // surfaces (bg → borders)
  mut: { 100:'#f4f4f5', 300:'#a1a1aa', 400:'#71717a', 500:'#52525b' }, // text (primary → faint)
}
// fonts: Inter (sans), JetBrains Mono (numeric / mono)
```

Usage: page bg `ink-900`, cards `ink-800`, inset fields `ink-700`, borders
`ink-600/700`. Text `mut-100` primary → `mut-500` faint. Accent only for the
primary button, focus rings (`focus:ring-matagi/50`), links, active state — one
accent per view. Status colors are standard Tailwind at low opacity: emerald
(ok), amber (write), red (destructive), blue (read), the accent (compose).

`globals.css` sets the dark base + themed scrollbar:

```css
:root { color-scheme: dark; }
html, body { background:#171717; color:#f4f4f5; font-family:'Inter',system-ui,'Segoe UI',sans-serif; }
::-webkit-scrollbar { width:10px; height:10px; }
::-webkit-scrollbar-track { background:#171717; }
::-webkit-scrollbar-thumb { background:#333333; border-radius:6px; }
```

Shape vocabulary: cards `rounded-xl border border-ink-600 bg-ink-800 p-3.5`;
pills `rounded-full px-2.5 py-0.5 text-[11px]`; primary button
`rounded-lg bg-matagi px-3 py-2 text-xs font-medium text-white hover:bg-matagi/90`;
inputs `rounded-lg border border-ink-600 bg-ink-700 px-3 py-2 text-sm focus:ring-2 focus:ring-matagi/50`;
layout is a single centered column (`mx-auto max-w-2xl px-4`), no sidebars. Type
scale is small: titles `text-sm font-medium`, body `text-xs`, meta `text-[11px]/[10px]`.

### Logos & attribution chip (required)

Both logos live in `public/` and are referenced by absolute path. Source the
**Matagi wordmark** and the **Claude mark** from `matagi.ai` (`/assets/matagi-logo-full.png`
and `/assets/logos/claude.png`). Render a fixed bottom-right chip on every page,
including login:

```tsx
// components/AttributionBubble.tsx — render once in the root layout
<a href="https://matagi.ai" target="_blank" rel="noreferrer"
  className="fixed bottom-4 right-4 z-50 flex items-center gap-2 rounded-full border border-ink-600 bg-ink-800/90 px-3 py-1.5 text-xs text-mut-300 shadow-lg backdrop-blur hover:border-matagi/60 hover:text-mut-100">
  <span className="text-mut-400">Built with</span>
  <img src="/matagi-logo.png" alt="Matagi" className="h-4 w-auto" />
  <span className="text-mut-500">&amp;</span>
  <img src="/claude-logo.png" alt="Claude" className="h-4 w-4 rounded-[3px]" />
  <span className="text-mut-300">Claude</span>
</a>
```

A logged-in header mirrors the brand: Matagi logo `h-6`, a Gmail mark + app name,
a connection dot + the mailbox address on the right, and a small bordered
**Log out** button wired to a `POST /api/logout`.

> **Middleware gotcha:** exclude static assets (`/_next`, `*.png`, `favicon.ico`)
> from the auth matcher, or logo/CSS requests 307-redirect to `/login` and the
> page renders bare.

---

## 2. Auth — single user, preset password

One allowlisted email. The password is a **preset secret stored in Matagi as a
custom credential** and injected into the deployment as an env var — never typed
into code, never in the browser. Two viable backings:

- **Lightweight (recommended for a single user, used here):** a server action
  compares the submitted password to the injected secret for the one allowed
  email, then sets a **signed httpOnly cookie**. Middleware verifies the cookie
  (Web Crypto HMAC, runs in the Edge runtime) and redirects to `/login`
  otherwise. No database.
- **Supabase Auth (when you need a user table / RLS):** provision Supabase via
  Matagi; create the auth user and set its password to the credential value via
  a one-shot service-role admin call; the browser uses only the anon key. Gate
  every page/route on: valid session + email on an allowlist + email equals the
  connected mailbox.

Sign the cookie with the same secret as the password (HMAC key = the credential):
rotating the password then invalidates every existing session for free.

```ts
// lib/auth.ts — Web Crypto HMAC so it runs in both Node and Edge
async function hmac(data: string) { /* importKey HMAC-SHA256 over env secret; return b64url(sign) */ }
export async function signSession(email: string)  { /* {email, exp} → `${body}.${sig}` */ }
export async function verifySession(token?: string) { /* recompute + constant-time compare + exp + allowlist */ }
export function checkCredentials(email: string, password: string) {
  // env var name = your Matagi custom-credential key (e.g. APP_LOGIN_PASSWORD)
  return email.trim().toLowerCase() === ALLOWED_EMAIL && password === process.env.APP_LOGIN_PASSWORD;
}
```

Login page shape: centered card, Matagi wordmark, app name + one-line subtitle,
email + password, one full-width accent submit button, a small "access
restricted" footnote. No social logins.

---

## 3. Runtime wiring (Matagi)

Provision with the Matagi MCP tools, in order:

1. `project_create` → a project to hold the resources.
2. `github_repo_provision` → a private repo to deploy from (mint a short-lived
   token with `github_repo_get_token`; push with user `x-access-token`). The
   fresh repo has an initial commit — `git pull --rebase --allow-unrelated-histories`
   before the first push.
3. `vercel_create_project` (framework `nextjs`, linked to the repo) → a stable
   branded URL `<resource_id>.built-with.matagi.ai`.
4. `vercel_share_user_connection` → share the Gmail connection into the runtime.
5. `vercel_share_user_secret` → share the preset password secret.
6. `vercel_deploy` → build + go live.

### Calling Matagi from inside the deployment

The deployment gets `MATAGI_RESOURCE_API_KEY` (auth), `MATAGI_CONNECTIONS_JSON`
(shared connections, each with a `connectionId` + `app`), and every shared secret
as a named env var. Base URL `https://api.dev.matagi.ai`.

```ts
// LLM completion — the platform holds provider keys; usage billed to the resource.
POST /v1/llm/complete
  { provider:"anthropic", model:"claude-sonnet-4-6", system, messages:[{role,content}], max_tokens, temperature }
  → { text, finish_reason, usage, cost }            // NOTE: plain text — no native tool-calling field

// Connection proxy — call the third-party app; credentials injected server-side.
POST /v1/connections/proxy
  { connectionId, method, url, headers?, body? }     // connectionId from MATAGI_CONNECTIONS_JSON
  → { status, body }

GET /v1/llm/models    // public — the allowed model ids per provider
```

Resolve the Gmail `connectionId` at runtime from `MATAGI_CONNECTIONS_JSON`
(`find(c => c.app === "gmail")`) rather than hardcoding it.

> **Secret propagation gotcha:** sharing a secret resolves and writes its value
> into the project's env store *at share time*. If you later edit the value in
> Matagi, a plain redeploy keeps the OLD value — you must **re-share the secret**
> (re-resolves the current value), then redeploy.

---

## 4. The agent loop

Matagi's LLM endpoint returns plain text — **no native tool calls** — so tool use
rides on a strict JSON protocol the model is told to emit. The loop is the
classic five lines: call model → JSON says final? done : run the tool, append the
result, repeat. Bounded by `MAX_STEPS`; near the cap, inject a "wrap up now" nudge.

Each assistant turn MUST be exactly one JSON object, no prose/fences:

```
{"thought":"<one short why>","action":{"tool":"<name>","args":{...}}}   // call a tool
{"thought":"<optional>","final":"<markdown answer to the user>"}        // done
```

Loop mechanics:
- Parse the first balanced `{...}` (strip code fences defensively). If parsing
  fails, treat the whole text as the final answer.
- **Never terminate on a bare "thought."** A turn with a thought but no `action`
  and no `final` is the model announcing intent ("let me compile the summary…")
  — if you treat that as the final answer, the agent stops one step short of
  doing the work. Instead, push a nudge ("emit an action or a final now") and
  continue the loop (bounded by `MAX_STEPS`). Also state in the prompt that every
  object MUST carry `action` or `final`.
- After a tool runs, append the assistant's raw JSON turn, then a `user` turn
  `TOOL_RESULT (<tool>): <json>` — **truncate** large results (~8k chars).
- Frame tool output as data, not instructions. Don't expose dangerous capabilities
  you don't want the model to have.
- Stream typed events over SSE so the UI can render progress live:
  `tool_call` (id, name, label, category, args) → `tool_result` (id, ok, result|error)
  → `final` (text) / `error`.

Inject **today's date** into the system prompt so relative queries
(`newer_than:7d`) work, since the model may not know the current date.

A tool is just `{ name, description, category, parameters, label(args), run(args) }`.
`run` does the side effect; `label` + `category` drive the card. Serialize the
catalog into the system prompt.

---

## 5. Gmail tool registry (the hard-won bits)

All calls go through the proxy to `https://gmail.googleapis.com/gmail/v1/users/me/...`.
Parse messages into a clean summary (`from, subject, date, snippet, unread,
starred, labelIds`) and extract the body by walking MIME parts (prefer
`text/plain`, fall back to stripped `text/html`).

Tools, grouped by `category` (drives card color):

- **read:** `get_profile`, `count_messages`, `search_messages`, `list_messages`,
  `get_message`, `list_labels`, `list_drafts`
- **write:** `mark_read`, `mark_unread`, `star`, `unstar`, `archive`, `modify_labels`
- **compose:** `create_draft`, `send_draft`, `send_message`, `reply`, `forward`
- **destructive:** `trash`

### Counting: never sample, use the counters

The number Gmail shows next to **Inbox** is the INBOX label's `messagesUnread`.
Do NOT infer counts from a listing tool (it returns a small capped sample, and
"at least N" is just wrong). Two exact paths:

- `get_profile` → one cheap call set: `GET /profile` + `GET /labels/INBOX` +
  `GET /labels/UNREAD`. Returns `messagesTotal`, `inboxTotal`, **`inboxUnread`**
  (the Gmail badge number), **`unreadTotal`** (unread across all mail), and a
  `measuredAt` timestamp.
- `count_messages(q, cap)` → for arbitrary queries, page through **ids only**
  (`fields=messages/id,nextPageToken,resultSizeEstimate`, up to 500/page — no
  per-message fetch). Exact under `cap`; a lower bound beyond it.

Always make the agent **state the scope**: "67 unread in your inbox (as of just
now)", or "at least 200 — I scanned the most recent 200".

### Finding: paginate in batches

`list_messages` is a quick peek (≤15, one metadata fetch per id). For "find / go
through my emails" use `search_messages(q, limit≤60)`: collect ids (paging if
needed), then fetch metadata in **concurrent batches** (e.g. 8 at a time) to stay
fast. Return `total` (Gmail estimate) vs `scanned` vs `truncated` so the agent can
honestly report how far it looked.

### Drafts vs sending (give the agent both)

Map intent precisely or the agent refuses correctly-but-unhelpfully:
- "draft / write / prepare" → `create_draft` (`POST /drafts {message:{raw}}`, saved, not sent).
- "send / email / reply" → `send_message` / `reply` (`POST /messages/send`, immediate).
- `send_draft` (`POST /drafts/send {id}`) promotes a draft.

Build the RFC-822 MIME once (`To/Cc/Bcc/Subject` + `text/plain` body, base64url)
and reuse it for both send and draft. `reply` fetches the original to thread
correctly (`Re:` subject + `threadId`) so the user only supplies the body.

**Forwarding must preserve attachments.** Gmail has no native forward endpoint,
and `send_message` only carries text — so a "forward" faked with send loses the
files. Implement a real `forward`: fetch the original `format=full`, walk the
payload for attachment parts (`filename` + `body.attachmentId`), download each via
`/messages/{id}/attachments/{attachmentId}`, then build a fresh `multipart/mixed`
message (a `text/plain` part with the quoted "---------- Forwarded message ----------"
header block + each attachment as a base64 part with `Content-Disposition:
attachment`) and `messages.send` it. Convert attachment data from base64url to
standard base64 and wrap at 76 cols. Tell the agent to ALWAYS use `forward` for
forwards, never send_message.

### Mutations

Read/unread/star/archive/label = `POST /messages/{id}/modify {addLabelIds, removeLabelIds}`
(`UNREAD`, `STARRED`, `INBOX`, or user label ids — resolve names via `list_labels`).
`trash` = `POST /messages/{id}/trash` (reversible 30 days; mark it the `destructive`
category). Resolve ids first: to act on "the email from X", search → then act.

---

## 6. Tool cards (the cool part)

Render each `tool_call` as a card: a Gmail mark in a rounded tile, the human
`label` ("Searching: is:unread", "Drafting email to …"), a category pill, the
mono `gmail.<tool>` subtitle, and a status icon (spinner → check / ✕). Cards are
expandable; the body renders the result by shape:

- a message list → compact rows (unread dot, sender, subject, snippet, ★)
- one full message → subject / from / date + scrollable body
- profile/counts → small stat tiles (use `tabular-nums`)
- labels → pills
- else → pretty-printed JSON

Final assistant text renders through a tiny, escaped Markdown renderer (bold,
lists, links, inline code). Empty state: a few suggestion chips. Input is a
textarea (Enter to send, Shift+Enter newline).

---

## 7. Conversation history & sidebar (optional, Supabase)

A ChatGPT/Claude-style left sidebar of past conversations, backed by a Supabase
Postgres that Matagi provisions for you (no Supabase account needed). Skip this
whole section and the app still works — it just won't persist chats.

### Provision (during the build, via the Matagi MCP)

1. `supabase_provision` (into the same project) → returns `resource_id` + a
   `supabase_project_ref`. Provisioning is async — poll `supabase_get_status`
   until `state: "active"`.
2. `supabase_execute_sql` to create the schema (two tables):

   ```sql
   create extension if not exists pgcrypto;
   create table conversations (
     id uuid primary key default gen_random_uuid(),
     user_email text not null,
     title text not null default 'New conversation',
     created_at timestamptz not null default now(),
     updated_at timestamptz not null default now()
   );
   create table messages (
     seq bigserial primary key,            -- preserves turn order
     id uuid not null default gen_random_uuid(),
     conversation_id uuid not null references conversations(id) on delete cascade,
     role text not null check (role in ('user','assistant')),
     content text not null default '',
     tools jsonb,                          -- the saved tool-card array (assistant turns)
     created_at timestamptz not null default now()
   );
   create index on conversations (user_email, updated_at desc);
   create index on messages (conversation_id, seq);
   alter table conversations enable row level security;  -- service-role bypasses RLS;
   alter table messages enable row level security;       -- with no policies, anon sees nothing
   ```

3. `supabase_promote_credential_to_user_secret` (credential `SUPABASE_SERVICE_ROLE_KEY`,
   give it an app-specific name like `APP_SUPABASE_SERVICE_ROLE_KEY`), then
   `vercel_share_user_secret` to inject it. The REST URL
   (`https://<project_ref>.supabase.co`) is public — bake it into config (it's not
   a secret). After DDL, run `notify pgrst, 'reload schema';` so PostgREST exposes
   the new tables immediately.

### App wiring

- **Data access (server-only):** a tiny PostgREST client over `fetch` (no SDK
  dependency). Headers: `apikey` + `Authorization: Bearer <service-role key>`.
  Endpoints: `POST/GET/PATCH/DELETE /rest/v1/{conversations,messages}` with
  `?col=eq.value`, `?order=…`, and `Prefer: return=representation|minimal`.
  Always filter conversations by `user_email` and verify ownership before
  returning messages — the service-role key bypasses RLS, so ownership checks
  live in your code.
- **Persist inside the chat stream:** the `/api/chat` route creates the
  conversation on the first message (title = the first user line), inserts the
  user row, then **wraps the agent's `emit`** to accumulate the tool-card array +
  final text, and inserts the assistant row when the loop finishes. Make history
  best-effort: wrap it in try/catch so a DB hiccup never breaks the chat. Emit a
  `{type:"conversation", id, isNew}` SSE event up front so the client learns the
  new id mid-stream.
- **History API:** `GET /api/conversations` (list), `GET /api/conversations/[id]`
  (messages, ownership-checked), `PATCH` (rename), `DELETE` (cascade).
- **UI:** a `Sidebar` (fixed 16rem column on desktop, slide-over on mobile) with a
  "New chat" button and the conversation list — active row highlighted, hover to
  reveal delete, double-click to rename inline. The `Workspace` shell owns
  `conversations`, `activeId`, and `turns`; opening a conversation hydrates
  `turns` from stored rows (assistant rows rebuild their tool cards from the saved
  `tools` jsonb), and after each send it refreshes the list so titles/order
  update. Degrade gracefully: if `GET /api/conversations` reports `enabled:false`
  (no DB key), the sidebar shows a "history is off" note and the app runs stateless.

## Build checklist

- [ ] Tailwind tokens (`matagi`, `ink.*`, `mut.*`), Inter + JetBrains Mono, dark scrollbar.
- [ ] `public/matagi-logo.png` + `public/claude-logo.png`; `<AttributionBubble/>` in root layout.
- [ ] Login page + signed-cookie auth; middleware gates routes and **excludes static assets**.
- [ ] Matagi: project → repo → Vercel project → share connection → share secret → deploy.
- [ ] Runtime client: `llmComplete` + `connectionProxy`; resolve `connectionId` from `MATAGI_CONNECTIONS_JSON`.
- [ ] JSON agent loop with SSE events; today's date in the system prompt; bounded steps.
- [ ] Gmail tools: exact counts via label counters, batched deep search, drafts + send, scope-reporting.
- [ ] Branded tool-card UI with per-category styling and shape-aware result views.
- [ ] (Optional) History: `supabase_provision` → schema → promote+share the
      service-role key; PostgREST client; persist in the chat stream; sidebar +
      conversations API; graceful no-DB fallback.
- [ ] To rotate the password: update the credential, **re-share the secret**, redeploy.
