I wanted a bot that would watch my inbox, pull out sentences like “Can you send the slides by Friday?” and park them directly in Todoist or Notion — with the due date, priority, and project already filled in. OpenClaw had most of the plumbing, but I couldn’t find a single write-up of the full flow. This post documents the working setup I now run on ClawCloud. Copy/paste liberally, or cherry-pick the patterns for your own stack.
Why care about email-to-task automation in 2024?
Email is still the default backlog for everything: vendor quotes, bug reports from the CEO, random FYIs. Triaging that manually is boring and error-prone. If a parser can promote action items to a structured task system, I only open Todoist or Notion to see what I have to do, not Gmail to guess what I missed.
There are third-party zaps for this, but they charge per task and treat every email as a task. I wanted extraction that respects context, handles subtasks, and pings me before it commits something stupid. OpenClaw gives you the control plane: IMAP polling, LLM-powered extraction, thin wrappers around the Todoist / Notion APIs, and an opinionated agent loop.
Architecture: glue two APIs with one agent
Pieces in play:
- Gateway – OpenClaw’s web UI for logs and config. We’ll deploy it on ClawCloud.
- Daemon – background worker that runs the agent every minute via scheduler.
- Email source – IMAP inbox (works with Gmail, Fastmail, Outlook).
- Parser – LLM chain that extracts
{action, due_date, priority, project}from the email text. - Destination – Todoist REST v9 or Notion public SDK.
- Feedback – message to me on Telegram confirming the created task, with a 🔁 emoji I can tap to undo (optionally Slack, Discord, etc.).
Everything runs in one .ts file plus two small helper modules. No separate microservices.
Step 1 – spin up an OpenClaw project
Requires Node 22+. I’m on v22.2.1.
# new workspace
mkdir claw-email-tasks && cd $_
# install CLI globally if you don't have it
npm i -g openclaw@^1.18.0 # 1.18 added the scheduler hooks we use
# init skeleton
openclaw init email2task
cd email2task
The wizard asks for the agent name, model (I picked gpt-4o-openclaw which ClawCloud hosts), and default timezone. Commit the generated openclaw.yml. We’ll edit it later for secrets.
Step 2 – wire up IMAP as a tool
OpenClaw delegates to node-imap. The tool wrapper lives under tools/imap.ts:
import { IMAPTool } from "@openclaw/tool-imap";
export const imap = new IMAPTool({
host: process.env.IMAP_HOST!,
user: process.env.IMAP_USER!,
password: process.env.IMAP_PASS!,
port: 993,
tls: true,
mailbox: "INBOX",
search: ["UNSEEN"],
});
Add these env vars to .env. If you’re on Gmail you need an App Password and IMAP_HOST=imap.gmail.com. For OAuth tokens I defer to the community recipe.
Expose the tool in openclaw.yml:
# openclaw.yml
...
tools:
- ./tools/imap.ts
At this point openclaw run should show a green check for IMAP connectivity.
Step 3 – build the email parsing chain
LLM prompts live in prompts/parse_email.md (plain text is easier to diff). Mine:
You are a strict action item extractor. For the given email body return a JSON array of tasks.
Each task must contain: "title", "due_date" (ISO-8601 or null), "priority" (1-4), "project" (string or null).
Ignore greetings, signatures, quoted text.
Provide no extra keys, no commentary.
### EMAIL
{{email}}
### END
Then the chain wrapper under chains/emailParser.ts:
import { ChatCompletionChain } from "openclaw/chains";
import parsePrompt from "../prompts/parse_email.md";
export interface ParsedTask {
title: string;
due_date: string | null;
priority: number;
project: string | null;
}
export const emailParser = new ChatCompletionChain({
prompt: parsePrompt,
schema: {
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
due_date: { type: ["string", "null"] },
priority: { type: "number" },
project: { type: ["string", "null"] },
},
required: ["title", "priority"],
},
},
model: "gpt-4o-openclaw",
});
OpenClaw will validate the JSON against the schema; malformed output triggers a retry with an error-prompt suffix. In testing, 3 retries cover 99% of edge cases.
Step 4 – Todoist integration
Todoist’s recent API v9 is simple enough to hand-roll. I wrapped the calls in tools/todoist.ts:
import ky from "ky";
export class TodoistClient {
private ky = ky.create({
prefixUrl: "https://api.todoist.com/rest/v2",
headers: { Authorization: `Bearer ${process.env.TODOIST_TOKEN}` },
});
async add(task: {
content: string;
due_string?: string;
priority?: 1 | 2 | 3 | 4;
project_id?: string;
}) {
return this.ky.post("tasks", { json: task }).json();
}
async projects() {
return this.ky.get("projects").json();
}
}
Note: Todoist priorities are reversed (1 = urgent), so I keep them aligned with email extraction but swap them when sending.
Notion integration
If you prefer Notion, install the SDK:
npm i @notionhq/client@2.2.8
import { Client } from "@notionhq/client";
export const notion = new Client({ auth: process.env.NOTION_TOKEN });
export async function addToDatabase(page: {
title: string;
due?: string | null;
priority?: number;
database_id: string;
}) {
return notion.pages.create({
parent: { database_id: page.database_id },
properties: {
Name: { title: [{ text: { content: page.title } }] },
Priority: { select: { name: page.priority?.toString() ?? "3" } },
Due: page.due ? { date: { start: page.due } } : undefined,
},
});
}
Store NOTION_TOKEN and the DATABASE_ID from the URL of your Tasks database.
Step 5 – main agent loop
Create agents/email2task.ts:
import { Agent } from "openclaw";
import { imap } from "../tools/imap";
import { emailParser } from "../chains/emailParser";
import { TodoistClient } from "../tools/todoist"; // or notion helpers
import { telegram } from "@openclaw/tool-telegram";
const todoist = new TodoistClient();
export default new Agent({
schedule: "* * * * *", // every minute
run: async (ctx) => {
const unread = await imap.read();
if (!unread.length) return;
const projects = await todoist.projects();
const projectMap = Object.fromEntries(
projects.map((p: any) => [p.name.toLowerCase(), p.id])
);
for (const mail of unread) {
const tasks = await emailParser.invoke({ email: mail.text });
for (const t of tasks) {
const project_id = t.project ? projectMap[t.project.toLowerCase()] : undefined;
const todo = await todoist.add({
content: t.title,
due_string: t.due_date ?? undefined,
priority: 5 - t.priority as 1 | 2 | 3 | 4, // invert
project_id,
});
await telegram.sendMessage({
chat_id: process.env.TG_CHAT!,
text: `Added task → ${todo.url}\n${t.title}${t.due_date ? ` • ${t.due_date}` : ""}`,
reply_markup: {
inline_keyboard: [[{ text: "Undo", callback_data: `undo:${todo.id}` }]],
},
});
}
await imap.move(mail, "Processed");
}
},
});
One file, 70 lines. Main points:
- Runs every minute via cron syntax.
- Marks processed mail as read and moves to a
Processedfolder (optional but keeps my inbox clean). - Sends me a Telegram DM confirmation with an Undo button. The callback handler lives in
tools/telegramUndo.tsand deletes the task if tapped within 10 minutes.
Step 6 – deploy to ClawCloud
# auth (browser pops)
openclaw login
# provision project
openclaw deploy --region us-east-1
The CLI zips the repo, installs dependencies, and spins up a 512 MB container. With the gateway UI you can tail logs, tweak env vars, and trigger a manual run.
Total cold-start to working bot: 4 minutes measured on a new account.
Parsing accuracy: what worked and what hurt
- Few-shot examples – I added three sample emails below the main prompt (not shown above to keep it short). Accuracy jumped from 78% to 93% on my 200-email test set.
- Quoted text – Gmail collapses quoted replies; still the parser sometimes picked up “On Mon John wrote…”. The instruction “Ignore quoted text” plus
--depth 2in the IMAP tool (fetch only the latest MIME part) fixed it. - Date math – “by Friday” must resolve relative to the email’s
Dateheader in the sender’s timezone, not mine. I pass that into the chain as{{sent_at}}and usechrono-nodeon the agent side before posting to Todoist/Notion. - Priority norm – people write “high priority”, “ASAP”, “no rush”. I mapped regexes to numeric hints (
ASAP→1,no rush→4) and let the LLM override.
Feedback loop & undo workflow
I initially pushed tasks silently and hoped for the best. In a week I had twenty junk tasks. Human-in-the-loop matters.
The Telegram undo handler:
import { TodoistClient } from "../tools/todoist";
import { telegram } from "@openclaw/tool-telegram";
telegram.onCallbackQuery(/^undo:(\d+)$/, async (ctx, [id]) => {
await new TodoistClient().ky.delete(`tasks/${id}`);
await ctx.answerCallbackQuery({ text: "Task removed" });
});
In Notion’s case I update the archived field to true.
This feedback loop also acts as ground truth: once a task survives 15 minutes I write the email + parsed JSON to a confirmed_tasks collection. That dataset now feeds fine-tuning experiments for our company model. The side benefit: zero hallucinated fields because the schema is strict.
Hard edges and production notes
- Rate limits – Todoist lets 50 write requests per minute. If you process a backlog, batch them (
/tasks/sync) or sleep. Notion is lazier (3 req/s). - HTML emails – strip markup with
turndown. LLMs get distracted by CSS. - Security – IMAP credentials live in ClawCloud’s secret store, not
.envin git. Two-factor tokens rotate monthly via the API. - Timezones – Todoist uses the project’s timezone, Notion stores UTC. Convert explicitly.
- Failures – if Todoist is down (rare), I enqueue tasks in SQLite and replay later. Ten lines of code, but avoids data loss.
Where to go next
This setup already trimmed my morning triage from 15 min to 3 min. Next experiment: use semantic search on the task history to avoid duplicates (“Send slides” surfaces a previous boring thread). If you rebuild, post issues on the GitHub discussion. The best tricks end up in the docs — and maybe save the rest of us another few minutes.