If you’ve ever lost a deal, an interview, or a partnership because you forgot to nudge someone, you already know why “send follow-up” is always on productivity bingo cards. The question engineers keep asking is: can OpenClaw run the whole sequence for me—drafting at the right intervals, personalizing from prior context, and tracking who got pinged when—without grafting on another SaaS subscription? Short answer: yes. Long answer is the rest of this post. I walk through the exact setup I’m running for sales outreach and job-search networking.
Why use OpenClaw for automated follow-up email sequences?
Three reasons:
- Single brain, many channels. My agent already lives on Slack, Telegram, and the browser. Email is just another tool in the toolbox.
- First-class memory. OpenClaw’s persistent vector store means the agent can reference the last conversation with Sara from Acme when writing the third nudge.
- No vendor lock-in. Everything runs on my VPS today. Tomorrow I can press the “Migrate to ClawCloud” button if I outgrow it.
Prerequisites and quick sanity check
Software versions that worked for me
- OpenClaw 0.42.3 (npm tag
latestas of May 2024) - Node.js 22.1.0 (earlier 20.x works but the scheduler has edge-case bugs)
- Composio Gmail connector 1.3.5
- PostgreSQL 16 (for structured CRM-ish tracking)
Install the basics
$ npm install -g openclaw@0.42.3 # gateway + daemon
$ openclaw daemon start # spawns in the background
$ openclaw gateway --port 3000 # web UI on http://localhost:3000
If you’d rather not run local, ClawCloud spins this up in ~60 seconds. I ran locally first to avoid burning credits while tinkering.
Connecting Gmail via Composio
OpenClaw piggybacks on Composio for 800+ integrations. Gmail is the one we need.
Create a Composio project
- Go to cloud.composio.dev, create a project, grab the
PROJECT_IDandPROJECT_SECRET. - Add Gmail as a source, pick “OAuth user flow,” and authorise your Google account.
Save credentials to OpenClaw
$ openclaw secrets set COMPOSIO_PROJECT="$PROJECT_ID"
$ openclaw secrets set COMPOSIO_SECRET="$PROJECT_SECRET"
The gateway will now show Gmail: connected.
Minimal agent: send one follow-up after two days
Let’s prove the plumbing works before we go full sequence.
Agent definition (agents/followup.js)
module.exports = ({ tools, memory }) => ({
name: "followup",
description: "Send follow-up emails after initial outreach",
schedule: "in 2 days",
async run (params) {
const { threadId, to, subject } = params;
const lastMail = await tools.gmail.getThread(threadId);
const body = `Hi ${to.firstName},\n\nJust checking whether you had a chance to look at my previous email. Happy to clarify any questions.\n\nBest,\nPeter`;
await tools.gmail.send({ to: to.email, subject: `RE: ${subject}`, body, threadId });
await memory.upsert("followup_log", { threadId, step: 1, sentAt: new Date() });
}
});
Register the agent:
$ openclaw agent add agents/followup.js
Trigger it manually to test:
$ openclaw agent run followup --threadId="1882abcf..." \
--to="{\"firstName\":\"Sara\",\"email\":\"sara@acme.com\"}" \
--subject="API partnership"
Sara gets the nudge, memory table logs it, we’re good.
Designing a multi-step follow-up sequence
Most people stick to three emails:
- Polite bump at +2 days
- Value add (article, template, code sample) at +5 days
- “I’ll close the loop” goodbye at +10 days
We’ll encode that in a YAML template the agent can read, so changing cadence later doesn’t require code edits.
config/followup.yml
steps:
- offset: 2d
template: |
Hi {{firstName}},
Just circling back on my email below. Happy to answer questions!
– {{senderName}}
- offset: 5d
template: |
Hi {{firstName}},
I put together a short demo repo that might clarify what I meant about {{topic}}. Thought you’d find it useful.
– {{senderName}}
- offset: 10d
template: |
Hey {{firstName}},
I haven’t heard back so I’ll assume timing isn’t right. My door’s open if that changes.
– {{senderName}}
Notice the {{topic}} placeholder—we’ll pull that automatically from conversation memory if it exists.
Personalizing drafts from conversation context
OpenClaw’s context API lets an agent ask: “what did we talk about in the last message?” and “what company is this person from?” Here’s a helper that turns raw messages into a context object.
lib/context.js
module.exports = async function buildContext (memory, threadId) {
const history = await memory.load(`gmail:${threadId}`);
const last = history.slice(-1)[0] || {};
const topicMatch = /about ([a-zA-Z0-9 _-]+)/i.exec(last.body || "");
return {
firstName: (last.toName || "").split(" ")[0] || "there",
company: last.toEmail ? last.toEmail.split("@")[1].split(".")[0] : "",
topic: topicMatch ? topicMatch[1] : "our discussion"
};
};
We’ll now upgrade the agent to compile templates with this context.
agents/followup.js (v2)
const fs = require("fs");
const yaml = require("yaml");
const Mustache = require("mustache");
const buildContext = require("../lib/context");
module.exports = ({ tools, memory, scheduler }) => {
const cfg = yaml.parse(fs.readFileSync("config/followup.yml", "utf8"));
async function sendStep (stepIdx, params) {
const { threadId, to, subject, senderName } = params;
const ctx = { ...(await buildContext(memory, threadId)), ...to, senderName };
const template = cfg.steps[stepIdx].template;
const body = Mustache.render(template, ctx);
await tools.gmail.send({ to: to.email, subject: `RE: ${subject}`, body, threadId });
await memory.upsert("followup_log", { threadId, step: stepIdx + 1, sentAt: new Date() });
}
return {
name: "followup",
description: "Automated multi-step email follow-up",
async run (params) {
// Kick off whole sequence at t=0
cfg.steps.forEach((step, idx) => {
scheduler.scheduleIn(step.offset, "followup_step", { ...params, step: idx });
});
}
};
};
And a companion handler for each scheduled step:
module.exports = ({ tools, memory }) => ({
name: "followup_step",
async run (params) {
const { step } = params;
await sendStep(step, params);
}
});
We now call openclaw agent add agents/followup_step.js too.
Scheduling and reminders: what actually happens at 3 AM?
Under the hood, the scheduler interface stores jobs in the same Postgres instance the gateway uses. No Redis required. Time zones are the tricky part. The scheduler stores offsets in UTC. If you want local-time nudges, capture the contact’s time zone in memory and schedule at an absolute timestamp.
Per-recipient local send time
const recipientTz = to.timezone || "America/New_York";
const sendAtLocal = DateTime.now().setZone(recipientTz).plus(Duration.fromISO(step.offset));
const sendAtUtc = sendAtLocal.toUTC();
scheduler.schedule(sendAtUtc, "followup_step", { ...params, step: idx });
I use luxon (npm i luxon) for date math because Intl alone still feels like hand-rolling a clock.
Tracking who you’ve contacted and when (CRM-lite)
A CSV works until it doesn’t. We’ll store contact + thread info in Postgres so the agent—and me via SQL—can see status at a glance.
Schema (runs once)
CREATE TABLE contacts (
id SERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
first_name TEXT,
company TEXT,
last_contacted TIMESTAMP,
next_step INTEGER DEFAULT 0,
thread_id TEXT
);
Every time sendStep fires we update this row:
await memory.query(
"UPDATE contacts SET last_contacted = $1, next_step = $2 WHERE email = $3",
[new Date(), stepIdx + 1, to.email]
);
That next_step flag is how the agent knows whether to schedule remaining steps if the recipient replies. See below.
Stopping the sequence on reply
We register a simple listener agent that fires every time a Gmail webhook tells us a new inbound arrived.
module.exports = ({ tools, memory, scheduler }) => ({
name: "inbound_reply_listener",
triggers: ["gmail.inbound"],
async run ({ message }) {
const threadId = message.threadId;
const contact = await memory.queryOne("SELECT email FROM contacts WHERE thread_id=$1", [threadId]);
if (!contact) return; // not our outreach
// Cancel pending follow-ups
await scheduler.cancel({ threadId });
await memory.query("UPDATE contacts SET next_step = -1 WHERE thread_id=$1", [threadId]);
}
});
Result: the moment Sara answers, future nudges evaporate. Your reputation thanks you.
Putting it all together: kickoff script
I keep a CLI script to drop new leads into the pipeline. Feed it a CSV, it seeds the agent and schedules step 0.
scripts/seed_leads.js
const fs = require("fs");
const csvParse = require("csv-parse/sync").parse;
const { execSync } = require("child_process");
const leads = csvParse(fs.readFileSync("leads.csv"), { columns: true });
leads.forEach(lead => {
execSync(`openclaw agent run followup \
--threadId=null \
--to='{"firstName":"${lead.first_name}","email":"${lead.email}","timezone":"${lead.tz}"}' \
--subject='Quick intro: ${process.env.SENDER_COMPANY} x ${lead.company}' \
--senderName='${process.env.SENDER_NAME}'`);
});
On first run we have no existing thread, so Gmail will create one. The agent stores the new threadId back to the contacts table inside sendStep(0).
Hard edges and trade-offs
- Deliverability. Gmail API enforces 2k sends/day across user+service accounts. If you blast more, switch to a workspace-level service account or use Mailgun’s HTTP API.
- Rate limits. The Composio Gmail connector adds its own 250 req/min. Okay for drafts, tight for bulk send. Batch into 50-lead chunks.
- HTML vs plaintext. My templates are plaintext on purpose—AI hallucinations in HTML footers yell “spam” to Gmail’s filters. If you must use HTML, sanitize aggressively.
- Data residency. OpenClaw memory is your Postgres. If you jump to ClawCloud, that moves to us-west-2 unless you pin a region.
- Error visibility. The default dashboard hides scheduler failures behind a tiny red icon. I wired it to PagerDuty via the
events.*stream.
What about job hunting vs sales?
The same stack works. Swap the templates:
- Networking follow-up: “Thanks for the coffee chat, mind introducing me to Alice in Platform?”
- Interview loop: “I enjoyed learning about the renderer pipeline—attaching a 2-page teardown of your Figma perf.”
Sequence length shrinks (usually two nudges) and send times matter: nobody loves a Sunday 7 AM ping. Add recipient.preferences.quietHours to context and skip those windows.
Next step: turn this into a shared package
Right now the logic lives across five files. The community asked for a plug-and-play @openclaw/email-sequence package on GitHub #6721. If you build it before I do, ping me—happy to dog-food.