If you landed here searching for how to build an OpenClaw daily digest combining email, calendar and tasks, skip the marketing fluff. This is the exact playbook I use to ship myself a single Slack DM every morning with: a Gmail summary, today’s Google Calendar events, overdue + due-today tasks from Todoist and Notion, the weather, plus a tiny custom SQL metric from our production DB. All glued together by an OpenClaw agent running under a cron job on ClawCloud.

Why bother rolling your own morning briefing?

There are SaaS dashboards that promise something similar, but they either miss half the integrations or lock you into their UI. OpenClaw is just Node (22+) plus a YAML config—so I can extend it, version it and keep the data in my infra. The trade-off: you spend an evening wiring APIs instead of clicking checkboxes. For me, owning the workflow is worth it.

Prerequisites and quick sanity checks

Versions & tooling

  • Node.js 22.2+ (OpenClaw now uses top-level await and the built-in fetch API)
  • OpenClaw v0.9.7 or later (npm i -g openclaw)
  • ClawCloud account (optional but easier than running a VM)
  • Gmail API enabled & OAuth2 client credentials
  • Google Calendar API enabled
  • Todoist API token OR Notion internal integration token (or both)
  • A weather source — I’ll use Open-Meteo (free, no key) for simplicity
  • A delivery channel: Slack, Telegram, email, whatever OpenClaw supports. I’ll show Slack.

Folder layout

openclaw-digest/ ├─ agent.yaml # declarative bits ├─ secrets.env # tokens & creds, loaded by dotenv └─ src/ └─ dailyDigest.mjs

Nothing fancy. I commit agent.yaml to Git but put secrets.env on ClawCloud’s secret store so it never hits GitHub.

Step 1 – Wiring Gmail summaries

OpenClaw’s Gmail tool (thanks to the Composio integration) can pull threads, but a full email dump at 08:00 is noise. I only want unread messages tagged Important since yesterday.

Add the tool in agent.yaml

name: daily-digest schedule: "0 8 * * *" # every day at 08:00 in server TZ conversationMemory: false language: "en-US" provider: openai:gpt-4o channels: - slack: $SLACK_BOT_TOKEN plugins: - gmail

The schedule line actually wires cron inside ClawCloud—you can leave it out here and do system cron; I prefer explicit in YAML because it travels with the agent.

Gmail query in the script

// src/dailyDigest.mjs import { gmail } from "openclaw/plugins/gmail.js"; export async function fetchImportantGmail() { const since = new Date(); since.setDate(since.getDate() - 1); const q = `is:unread label:important after:${Math.floor(since.getTime()/1000)}`; const threads = await gmail.searchThreads({ q, maxResults: 20 }); if (!threads.length) return "No new important emails. ✉️"; const lines = await Promise.all(threads.map(async (t) => { const meta = await gmail.getThreadMetadata(t.id); return `• ${meta.from} — ${meta.subject}`; })); return lines.join("\n"); }

The Gmail plugin streams headers quickly; fetching full bodies is 10× slower, so I stick to metadata. Add a ⚠️ somewhere if you have unread counts > 20; you don’t want to spam yourself with 80 lines.

Step 2 – Pull today’s Google Calendar events

The Calendar plugin groups events nicely. The trick is getting correct time-zones when your server runs UTC.

Helper util

import { calendar } from "openclaw/plugins/google-calendar.js"; import { DateTime } from "luxon"; export async function fetchCalendar() { const now = DateTime.local().setZone("America/New_York"); const start = now.startOf('day').toISO(); const end = now.endOf('day').toISO(); const events = await calendar.listEvents({ timeMin: start, timeMax: end }); if (!events.length) return "No meetings today. 🚀"; return events.map(e => `• ${e.startTime.substring(11,16)} ${e.summary}`).join("\n"); }

I like showing only HH:MM local time — no attendees, no links; Slack will unfurl Meet links anyway. If you need multiple calendars, loop over IDs and merge.

Step 3 – Fetch tasks from Todoist and Notion

I still use Todoist for personal errands and Notion for team sprints. Combining both gives me a single “do these first” list.

Todoist block

import { todoist } from "openclaw/plugins/todoist.js"; export async function fetchTodoist() { const tasks = await todoist.getTasks({ filter: "(overdue | today) & !@waiting" }); if (!tasks.length) return null; // skip section if empty return tasks.map(t => `□ ${t.content}`).join("\n"); }

Notion block

import { notion } from "openclaw/plugins/notion.js"; const DATABASE_ID = process.env.NOTION_TASKS_DB; export async function fetchNotion() { const { results } = await notion.queryDatabase({ database_id: DATABASE_ID, filter: { and: [ { property: "Status", select: { equals: "In Progress" } }, { property: "Due", date: { on_or_before: new Date().toISOString() } } ] } }); if (!results.length) return null; return results.map(p => `□ ${p.properties.Name.title[0].plain_text}`).join("\n"); }

Both snippets flatten tasks to plain text. No rich markdown—Slack supports it, but too much styling makes the briefing noisy.

Step 4 – Add weather and a custom metric

Weather is five lines thanks to Open-Meteo. The custom metric showcases that anything returning JSON can join the digest.

Weather

export async function fetchWeather() { const { latitude, longitude } = { latitude: 40.71, longitude: -74.0 }; // NYC const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true&temperature_unit=fahrenheit`; const { current_weather: w } = await (await fetch(url)).json(); return `Weather: ${w.temperature}°F, ${w.windspeed} mph wind, ${w.weathercode}`; }

Custom SQL metric

I want “paying customers signed up yesterday” because it tells me how launch campaigns performed.

import pg from "pg"; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); export async function fetchSignups() { const { rows } = await pool.query( "SELECT count(*) FROM users WHERE plan = 'pro' AND created_at >= now() - interval '1 day'" ); return `🛒 New Pro signups: ${rows[0].count}`; }

Yup, mixing a production DB call in an agent can be scary. Give it a read-only role, or push the metric to an internal API instead.

Step 5 – Stitch it together into one message

All fetchers return strings or null. Concatenate with section headers so I can visually parse the Slack block in three seconds.

import { fetchImportantGmail, fetchCalendar, fetchTodoist, fetchNotion, fetchWeather, fetchSignups, } from "./modules.js"; // pretend we re-exported import { slack } from "openclaw/channels/slack.js"; export default async function run() { const parts = []; parts.push(`# Daily Digest — ${new Date().toLocaleDateString('en-US', { weekday: 'long', month:'short', day:'numeric' })}`); parts.push("\n*Email*\n" + await fetchImportantGmail()); parts.push("\n*Calendar*\n" + await fetchCalendar()); const todoist = await fetchTodoist(); if (todoist) parts.push("\n*Todoist*\n" + todoist); const notion = await fetchNotion(); if (notion) parts.push("\n*Notion*\n" + notion); parts.push("\n" + await fetchWeather()); parts.push(await fetchSignups()); const text = parts.join("\n"); await slack.send({ channel: process.env.SLACK_USER_ID, text }); }

Note two small hacks:

  • I send to myself via SLACK_USER_ID. For channels, use the channel ID.
  • The first line is a Markdown H1 (#) because Slack renders it bold and bigger. You can tweak for Telegram/Discord by adding if blocks per channel.

Step 6 – Scheduling: cron vs. OpenClaw scheduler

If you run on ClawCloud, the YAML schedule: field fires inside the hosted daemon. I migrated from system cron because cron’s email alerts kept spamming me on non-zero exit. Up to you.

Using system cron locally

# crontab -e 0 8 * * * /usr/bin/env NODE_ENV=production /usr/local/bin/openclaw run daily-digest >> /var/log/digest.log 2>&1

Make sure you point to the exact agent by name. OpenClaw’s CLI caches state, so restart the daemon if you push new code.

Step 7 – Choosing a delivery channel

Slack works for me, but the same script can branch on an env var:

const channel = process.env.DELIVERY || "slack"; if (channel === "telegram") { import { telegram } from "openclaw/channels/telegram.js"; await telegram.send({ chatId: process.env.TELEGRAM_CHAT_ID, text }); } else if (channel === "email") { import { sendmail } from "openclaw/channels/email.js"; await sendmail({ to: process.env.EMAIL_TO, subject: "Daily Digest", text }); } else { await slack.send({ channel: process.env.SLACK_USER_ID, text }); }

Pro tip: For SMS, pipe to Twilio with openclaw/plugins/twilio.js. Keep the text under 160 chars or you’ll burn credits fast.

Step 8 – Tweaking format & common pitfalls

1. Message too long

Slack truncates at 40 KB JSON payload. Filter aggressively. I cap Gmail to 10 threads, Calendar to 15 events.

2. OAuth token expiry

The Google tokens last one hour. The Composio wrapper refreshes automatically if you stored refresh tokens; verify scopes include offline_access.

3. Time-zones gone wild

Run process.env.TZ="America/New_York" for the node process, or keep converting with Luxon. Mixing system TZ and Calendar event TZs will bite you.

4. ClawCloud cold starts

Agents marked as scheduled only may sleep. First run might be 2-3 s slower. If you care, hit the healthcheck endpoint at 07:59 with UptimeRobot.

What it looks like at 08:00

# Daily Digest — Tue Apr 30 *Email* • alice@corp.com — infra outage postmortem draft • bob@acme.io — Invoice for March 2024 *Calendar* • 10:00 Stand-up • 14:30 Design Review w/ UX *Todoist* □ Pay quarterly taxes □ Buy coffee beans *Notion* □ Release notes PR to review Weather: 58°F, 6 mph wind, 2 (partly cloudy) 🛒 New Pro signups: 7

Tight, skimmable, no scrolling.

The practical takeaway

You now have a reproducible recipe: fetchers for each data source, a stitcher, and a scheduler. The pattern scales—want GitHub PRs or PagerDuty incidents? Write another fetch* that returns a string. Drop it in the parts[] array. Your future self will thank you tomorrow morning.