Every remote team hits the same wall: "What time works for everyone?" One person lives in Berlin, another in São Paulo, the third just moved to Tokyo. You burn twenty Slack messages lining up slots, then somebody’s DST flipped and the whole thing collapses. This article shows—line by line—how I solved that with OpenClaw 3.4.1, Composio calendar connectors, and a 40-line agent script. End result: a Calendly-style link that knows all participant time zones, polls their availability, and drops a Zoom link on everyone’s calendar automatically.

Why OpenClaw instead of yet-another scheduling SaaS?

Off-the-shelf schedulers (Calendly, SavvyCal, etc.) work great when you only need inbound booking. They break down when you need:

  • Multiple internal participants with different calendars (Google + Outlook)
  • Slack or WhatsApp reminders instead of email only
  • Custom logic (e.g., never schedule past 4 PM local time for on-call engineers)
  • Full auditability—your code, your infra

OpenClaw is a Node.js agent framework (requires Node 22+). You own the logic, you own the data, you deploy wherever. Via Composio you get 800+ integrations including Google Calendar, Microsoft 365, Zoom, Meet, Slack, and even iCal files. That combo lets us stay inside company policy while shipping something our scheduler can’t handle.

Prerequisites

Accounts & keys

  • Google Workspace or personal Google account
  • Optional: Microsoft 365 if you have Outlook users
  • Zoom or Google Meet for video links (Zoom shown below)
  • A Composio account (free tier is fine)
  • Node 22+ and npm 10+
  • OpenClaw 3.4.1 (latest at the time of writing)

Environment setup

# macOS / Linux brew install fnm # or use nvm fnm install 22 fnm use 22 npm --version # should show 10.x

Clone your agent repo:

git clone https://github.com/your-org/remote-scheduler.git cd remote-scheduler npm init -y npm install openclaw@3.4.1 composio-sdk@2.7.0 luxon@3.4.3 # luxon for time zone math

Log in to Composio and create an OAuth app for Google Calendar and Zoom. Copy the client IDs into .env:

# .env COMPOSIO_CLIENT_ID=xx COMPOSIO_CLIENT_SECRET=yy

Wiring OpenClaw to calendars

OpenClaw agents expose “tools”—thin wrappers over APIs. Composio ships with Google Calendar and Zoom tools already.

// tools.ts import { googleCalendar, zoom } from "composio-sdk"; export const tools = [googleCalendar(), zoom()];

Then import the tools in your agent gateway:

// index.ts import { createGateway } from "openclaw"; import { tools } from "./tools"; createGateway({ name: "tz-scheduler", tools, memory: "sqlite", // small demo, swap with Postgres in prod port: 8787, });

Run the gateway:

npx openclaw dev # starts localhost:8787

Hit http://localhost:8787, sign in with Google, authorise Zoom. You now have read/write tokens stored in encrypted SQLite.

Making the agent timezone-aware

JavaScript’s Date makes time zone work brittle. Use Luxon, the least-painful option I found.

// tz-util.ts import { DateTime } from "luxon"; export function toISO(dateStr: string, zone: string) { return DateTime.fromISO(dateStr, { zone }).toUTC().toISO(); } export function nice(dt: DateTime) { return dt.setLocale("en").toFormat("ccc, dd LLL yyyy HH:mm ZZZZ"); }

When we read a participant’s calendar events, we’ll store them in UTC. At display time we convert back to the participant’s zone.

Collecting availability from participants

Your first temptation might be brute-force 24×7 polling. That triggers API quotas quickly. Instead we:

  1. Detect each participant’s calendar working hours.
  2. Map them to UTC for a shared availability matrix.
  3. Intersect the slots.

Agent code (scheduler.ts) below. Truncated for space but these ~40 lines do the core job.

// scheduler.ts import { Agent } from "openclaw"; import { googleCalendar } from "composio-sdk"; import { DateTime, Interval } from "luxon"; import { toISO } from "./tz-util"; export const scheduler = new Agent("scheduler", async (ctx) => { const { members, durationMin, daysAhead } = ctx.input; // 1. Fetch events in parallel const calendars = await Promise.all( members.map((m: any) => googleCalendar.events.list({ calendarId: "primary", timeMin: new Date().toISOString(), timeMax: DateTime.utc().plus({ days: daysAhead }).toISO(), singleEvents: true, orderBy: "startTime", auth: m.token, }) ) ); // 2. Build busy intervals per user const busy: Interval[][] = calendars.map((res) => res.items.map((e: any) => Interval.fromISO(`${e.start.dateTime}/${e.end.dateTime}`) ) ); // 3. Scan day chunks and pick a slot everyone is free const slot = findSlot(busy, durationMin); return { slot }; });

Gotcha: Google Calendar returns events in the event owner’s zone, not yours. Normalize immediately with toISO.

findSlot helper

// find-slot.ts export function findSlot(busy: Interval[][], duration: number) { const start = DateTime.utc().plus({ hours: 2 }); // give folks lead time const end = start.plus({ days: 14 }); let cursor = start; const step = { minutes: 15 }; while (cursor < end) { const candidate = Interval.fromDateTimes(cursor, cursor.plus({ minutes: duration })); const clash = busy.some((b) => b.some((i) => i.overlaps(candidate))); if (!clash) return candidate; cursor = cursor.plus(step); } throw new Error("No common slot found in next 14 days"); }

We now have a UTC start/end pair everyone can attend. Time to notify people.

Polling users via Slack and email

OpenClaw’s gateway already knows each user’s Slack ID (if you linked Slack). We send interactive messages with three buttons: 👍 works, 🤔 maybe, 👎 can’t.

// notify.ts import { slack } from "composio-sdk"; import { nice } from "./tz-util"; export async function askApproval(members, slot) { const dt = nice(slot.start); for await (const m of members) { await slack.chat.postMessage({ channel: m.slackId, text: `Proposed meeting: ${dt} (${m.tz}). Respond with /accept, /tentative, /decline`, auth: m.token, }); } }

Yes, polling via interactive Slack buttons would be cleaner, but custom slash commands avoid the “App Home” install step. Trade-offs.

Users on email-only get a standard RFC 5545 iCalendar invite with PARTSTAT=NEEDS-ACTION. They click accept and Google sends a webhook back (Composio does the plumbing).

Once we have unanimous 👍 or a quorum you define, we book the slot.

// book.ts import { googleCalendar, zoom } from "composio-sdk"; export async function book(slot, members) { // 1. Create Zoom meeting const zoomRes = await zoom.meetings.create({ topic: "Team Sync", start_time: slot.start.toISO(), duration: slot.length("minutes"), timezone: "UTC", }); const meetingUrl = zoomRes.join_url; // 2. Create calendar event for each participant for await (const m of members) { await googleCalendar.events.insert({ calendarId: "primary", sendUpdates: "all", requestBody: { summary: "Team Sync", description: `Zoom: ${meetingUrl}`, start: { dateTime: slot.start.toISO() }, end: { dateTime: slot.end.toISO() }, attendees: members.map((x) => ({ email: x.email })), conferenceData: { createRequest: { requestId: `openclaw-${Date.now()}` }, }, }, auth: m.token, }); } }

Heads-up: If you’re on a free Zoom plan, API-created meetings have a 40-minute cap. Swap with Google Meet by replacing the Zoom step with conferenceData request only.

You can expose the agent via the gateway’s HTTP API. Let’s mount /book and accept query params ?members=alice,bob&duration=30.

// api.ts import express from "express"; import { scheduler } from "./scheduler"; import { askApproval } from "./notify"; import { book } from "./book"; const app = express(); app.use(express.json()); app.post("/book", async (req, res) => { try { const { members, duration } = req.body; const { slot } = await scheduler.run({ members, durationMin: duration, daysAhead: 14 }); await askApproval(members, slot); return res.json({ slot }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.listen(3030, () => console.log("Scheduler API on :3030"));

Behind Nginx you can assign https://meet.your-org.dev/. People hit it, pick duration, paste participant emails, done. That URL is your Calendly.

Putting the whole thing on autopilot

Human nature: someone will forget to click 👍 in Slack. I added a reminder job:

// remind.ts import { createCron } from "openclaw"; import { slack } from "composio-sdk"; createCron("0 * * * *", async () => { // every hour const pending = await db.pendingApprovals.findMany(); for (const p of pending) { await slack.chat.postMessage({ channel: p.slackId, text: `Reminder: please confirm the ${p.meeting} invite`, }); } });

OpenClaw’s daemon keeps cron tasks alive even if the gateway restarts (openclaw daemon start).

Security considerations

  • Least-privilege OAuth scopes: Composio lets you pick. You only need calendar.events.readonly + calendar.events.write. Don’t grant Gmail if you don’t use it.
  • Token storage: The default sqlite store is AES-256 encrypted. For enterprise, back it with HashiCorp Vault via OpenClaw’s vault: driver.
  • Audit logs: Enable LOG_LEVEL=debug when reproducing bugs, but turn it off in prod—calendar events may contain PII.
  • Rate limits: Google Calendar hard-caps at 10 requests/s per user. Batch calls or spread them with p-limit.

Testing end-to-end

Integration tests catch time zone edge cases early. I use Vitest with a fixed fake clock.

// scheduler.test.ts import { describe, it, expect, vi } from "vitest"; import { scheduler } from "./scheduler"; describe("finds slot across DST", () => { it("handles Europe/Berlin to America/New_York", async () => { vi.setSystemTime(new Date("2024-10-27T00:00:00Z")); // day Europe DST ends const slot = await scheduler.run({ members: demoMembers, durationMin: 30, daysAhead: 3, }); expect(slot).toBeDefined(); }); });

Set the fake clock to DST transition days. You don’t want a 9 AM meeting turning into 8 AM for half the team.

Cost breakdown

  • OpenClaw OSS: free (MIT)
  • ClawCloud hosting (optional): $29/mo for 3 agents, includes 1 M tool invocations
  • Composio free tier: 2 K actions/mo
  • Zoom API: included in paid Zoom account, else 40-minute limit

Total for a 10-person team: roughly $0 if self-hosted, $29/mo if you want ClawCloud’s managed Redis + HTTPS certs.

What we learned

OpenClaw lets you outgrow point-solution schedulers without building a whole microservice: read calendars, crunch time zones with Luxon, push Slack prompts, and finally create the event. The agent above took a weekend to write; most of the hours went into OAuth fiddling, not scheduling logic. If you’re drowning in "does this time work?" threads, put this repo behind a URL and move on to work that matters.

Next step: fork the sample code (link below), wire your OAuth creds, and ship your own /book endpoint. Questions or patches—open an issue on GitHub, I watch that repo daily.