Agentic Gmail
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.
Paste into Claude, Cursor, or Codex with the Matagi MCP connected. No account? Set up your MCP →
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:
- Design — dark Matagi theme, one warm accent, an attribution chip, a login page.
- Auth — single-user, a preset password injected as an env secret, signed cookie.
- Runtime — a Next.js app on Vercel that calls the Matagi API for LLM completions and for proxied Gmail calls.
- 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)
- 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.)
- 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. - 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:
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:
: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:
// 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">&</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/loginand 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
/loginotherwise. 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.
// 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:
project_create→ a project to hold the resources.github_repo_provision→ a private repo to deploy from (mint a short-lived token withgithub_repo_get_token; push with userx-access-token). The fresh repo has an initial commit —git pull --rebase --allow-unrelated-historiesbefore the first push.vercel_create_project(frameworknextjs, linked to the repo) → a stable branded URL<resource_id>.built-with.matagi.ai.vercel_share_user_connection→ share the Gmail connection into the runtime.vercel_share_user_secret→ share the preset password secret.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.
// 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
actionand nofinalis 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 byMAX_STEPS). Also state in the prompt that every object MUST carryactionorfinal. - After a tool runs, append the assistant's raw JSON turn, then a
userturnTOOL_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. ReturnsmessagesTotal,inboxTotal,inboxUnread(the Gmail badge number),unreadTotal(unread across all mail), and ameasuredAttimestamp.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 undercap; 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)
-
supabase_provision(into the same project) → returnsresource_id+ asupabase_project_ref. Provisioning is async — pollsupabase_get_statusuntilstate: "active". -
supabase_execute_sqlto create the schema (two tables):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 -
supabase_promote_credential_to_user_secret(credentialSUPABASE_SERVICE_ROLE_KEY, give it an app-specific name likeAPP_SUPABASE_SERVICE_ROLE_KEY), thenvercel_share_user_secretto inject it. The REST URL (https://<project_ref>.supabase.co) is public — bake it into config (it's not a secret). After DDL, runnotify 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=…, andPrefer: return=representation|minimal. Always filter conversations byuser_emailand 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/chatroute creates the conversation on the first message (title = the first user line), inserts the user row, then wraps the agent'semitto 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. TheWorkspaceshell ownsconversations,activeId, andturns; opening a conversation hydratesturnsfrom stored rows (assistant rows rebuild their tool cards from the savedtoolsjsonb), and after each send it refreshes the list so titles/order update. Degrade gracefully: ifGET /api/conversationsreportsenabled: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; resolveconnectionIdfromMATAGI_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.
Build this on Matagi
Connect the Matagi MCP to Claude, Cursor, or Codex, hand it this skill, and it provisions the infrastructure and ships the agent for you.
Get started