This post walks through how to build a personal CRM with OpenClaw email tracking. We will pipe every email you send or receive into OpenClaw, extract contacts, log interactions, push the data to Supabase (or Notion if you prefer), and schedule automatic reminders like “ping everyone I ignored for 90 days”. Everything here runs with OpenClaw v2.4.3 (Node 22+), Supabase 1.168.0, and the Composio Gmail connector v0.14.1.

Why bother rolling your own CRM?

I tried Superhuman, Streak, and yet another Airtable template. All failed the same tests:

  • Vendor lock-in — exporting my full relationship history is painful.
  • Limited automation hooks — I want arbitrary TypeScript, not “Zapier if-this-then-that”.
  • Cost scales with contacts. I have >20k historical emails; SaaS pricing hurts.

OpenClaw already sits between my chat apps, calendar, and shell. Adding email means I get a unified event stream and can run all logic locally or on ClawCloud. No new silo, no monthly CRM invoice.

Architecture at a glance

We will glue four pieces together:

  1. OpenClaw gateway — the web UI + agent runtime.
  2. Composio Gmail integration — streams emails to OpenClaw.
  3. Supabase — Postgres + Realtime for contact & interaction tables (replaceable with Notion DB if you hate SQL).
  4. Scheduler — OpenClaw’s built-in cron that runs follow-up jobs daily.

The data flow looks like:

  • Email hits Gmail → Composio pushes a webhook event.
  • OpenClaw agent parses it, normalises addresses, updates Supabase.
  • Once per day a scheduled task runs “who needs attention?” and sends a digest to Slack.

Bootstrap OpenClaw with the Gmail connector

1. Install / upgrade OpenClaw

If you self-host:

npm i -g openclaw@2.4.3 # requires Node 22+

Or spin up an instance on ClawCloud (“Create agent → Email CRM”). The cloud template installs the same version and pre-creates a Postgres cluster; skip the Docker port mapping headaches.

2. Enable the Gmail source

Inside ~/.claw/gateway.yaml add:

sources: gmail: provider: composio version: 0.14.1 oauth: clientId: $GOOGLE_CLIENT_ID clientSecret: $GOOGLE_CLIENT_SECRET redirectUri: https://gateway.yourdomain.dev/oauth/callback watchLabels: ["INBOX", "SENT"]

Restart the daemon:

claw daemon restart

Authenticate in the gateway UI → Integrations → Gmail → “Connect”. You should see live events in the Activity Stream.

Supabase schema for contacts & interactions

We need two tables:

  1. contacts — one row per email address (later you could merge duplicates).
  2. interactions — every inbound/outbound email.
-- /supabase/migrations/20240516100126_personal_crm.sql create table public.contacts ( email text primary key, first_name text, last_name text, avatar_url text, total_threads int default 0, last_interaction timestamptz ); create table public.interactions ( id uuid primary key default gen_random_uuid(), email text references public.contacts(email), thread_id text, direction text check (direction in ('INBOUND','OUTBOUND')), subject text, snippet text, ts timestamptz default now() );

Give the OpenClaw service role an anon key with insert/update rights on both tables.

Alternative: Notion database

If SQL makes your eye twitch, create a Notion DB with the same columns and use Composio’s Notion v0.9.7 connector. Change a single handler later; everything else is identical.

Writing the email handler in TypeScript

Create ~/claw/skills/email-crm.ts:

import { Skill, z } from "openclaw"; import { createClient } from "@supabase/supabase-js"; const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!); export default new Skill({ name: "email-crm", description: "Logs emails to personal CRM tables", trigger: { source: "gmail.message", // event name from Composio filter: z.object({}) // accept everything }, async run(ctx) { const msg = ctx.event.payload; // raw Gmail JSON const direction = msg.labelIds.includes("SENT") ? "OUTBOUND" : "INBOUND"; const addresses = direction === "OUTBOUND" ? msg.payload.headers.find(h => h.name === "To").value.split(/,\s*/) : [msg.payload.headers.find(h => h.name === "From").value]; for (const addr of addresses) { const email = addr.match(/<(.+?)>/)?.[1] ?? addr.trim(); const [first, last] = email.split("@")[0].split("."); await supabase.from("contacts").upsert({ email, first_name: first, last_name: last, last_interaction: new Date(msg.internalDate * 1), }); await supabase.from("interactions").insert({ email, thread_id: msg.threadId, direction, subject: msg.payload.headers.find(h => h.name === "Subject").value, snippet: msg.snippet, ts: new Date(parseInt(msg.internalDate)), }); } } });

A quick reload:

claw skills reload email-crm

Send yourself a test email. Query Supabase:

select * from contacts order by last_interaction desc limit 5;

You should see the new row.

Scheduling follow-up reminders

OpenClaw ships with a lightweight cron runner based on node-cron. Add another skill:

import { Skill } from "openclaw"; import { differenceInDays } from "date-fns"; export default new Skill({ name: "followup-digest", schedule: "0 7 * * *", // every day at 07:00 async run() { const { data: stale } = await supabase.rpc("contacts_needing_ping", { threshold_days: 90 }); if (!stale?.length) return; const lines = stale.map(c => `• ${c.email} — last seen ${differenceInDays(new Date(), new Date(c.last_interaction))} days ago`).join("\n"); await ctx.slack.postMessage({ channel: "#crm", text: `People you owe a nudge (90d+ inactivity):\n${lines}` }); } });

Use a Postgres function to calculate stale contacts:

create or replace function public.contacts_needing_ping(threshold_days int) returns setof contacts language sql as $$ select * from contacts where (now() - last_interaction) > (threshold_days || ' days')::interval; $$;

You now get a Slack DM each morning. Swap Slack for Email/Telegram/Signal — OpenClaw doesn’t care.

Natural-language queries inside OpenClaw

Because OpenClaw is an agent framework, nothing stops you from chatting with it:

You → "When did I last email Sarah?" Claw → "You last emailed sarah.liu@example.com five days ago on 12 May about 'Quarterly Roadmap'."

Implementation sketch:

  1. The human message hits the gateway on Telegram/Slack.
  2. A routing skill detects “last email” intent with a regex or your LLM of choice.
  3. Skill queries Supabase:
const { data } = await supabase .from("interactions") .select("ts, subject") .eq("email", "sarah.liu@example.com") .order("ts", { ascending: false }) .limit(1);

Same idea for “who haven’t I contacted in 3 months?”. Just pass threshold_days = 90 to the RPC above and enumerate results.

Notion variant: zero-SQL contact store

If your team already lives in Notion, replace the Supabase calls with Notion pages:

import { Client as Notion } from "@notionhq/client"; const notion = new Notion({ auth: process.env.NOTION_TOKEN }); await notion.pages.create({ parent: { database_id: process.env.NOTION_DB_CONTACTS! }, properties: { Email: { email: email }, "Last Interaction": { date: { start: new Date(msg.internalDate * 1).toISOString() } } } });

Downside: queries are slower, and you lack SQL date arithmetic. I still prefer Supabase, but the community asked, so here you go.

Hard edges & TODOs

  • Thread deduping — Gmail fires one event per recipient in “To” and “Cc”. I run a unique(email, thread_id, direction) index to ignore duplicates.
  • Name resolution — The naive first.last split breaks for “sales@” addresses. You can hit Gravatar or Clearbit if you need polish.
  • OAuth token refresh — Composio auto-refreshes but still fails 0.3 % of the time (see GitHub issue #9321). I retry with exponential backoff.
  • GDPR — If you store personal data, encrypt the contacts table or run Supabase inside your EU region.
  • LLM cost — Chatting with the agent about your contacts is cool until you dump 500 threads into GPT-4. I chunk at 2 KB and summarise.

Next step: add calendar context

Emails only capture a slice of relationships. The same Supabase schema can store meetings from Google Calendar. Flip on the Composio Calendar source, reuse the contacts table, and your morning digest now says “It’s been 90 days and no email or meeting with Sarah”. Once you trust the data, you can automate the follow-up: ask OpenClaw to draft the catch-up email, CC yourself, and hit send.

That’s it. You now own the full relationship graph, run by TypeScript you can edit anytime, with no per-seat CRM tax. If you build something on top, ping the GitHub discussion thread — I’m merging good PRs.