Skill: tradmangh/m365-calendar (version 0.1.9)

--- name: m365-calendar description: MS365 / Microsoft365 calendar automation via Microsoft Graph for Microsoft 365 (M365) Business (work/school, Exchange Online) and M365 Home/Consumer (hotmail.com, outlook.com, live.com). Use when listing upcoming events, searching calendar entries (e.g. “Lunch”), checking attendee response status (accepted/declined/tentative), creating or updating meetings, moving events to a new time, or troubleshooting Graph/MSAL auth/token cache for calendar access. Related keywords: OneDrive, SharePoint, Exchange Online (calendar). Privacy note: no third-party API key is required; authentication is via your own Microsoft login (device code) and tokens are stored locally per profile. **Token cost:** ~600-1.5k tokens per use (skill body ~2-3k tokens, Graph calls + light parsing). --- # M365 Calendar (Microsoft Graph) Keep this skill lean: do the heavy lifting with the bundled scripts. ## Installation / runtime requirements - Requires **Node.js** (the scripts are Node ESM). - This skill declares its npm dependency in `package.json`. - After installing/updating the skill, install deps in the skill folder: ```bash cd skills/m365-calendar npm install ``` ## Security / boundaries - Never commit or share token caches or client secrets. - Default secret location (per machine): `~/.openclaw/secrets/m365-calendar/` ## Quick start ### 0) First question: do you want to connect **M365 Business** or **M365 Home/Consumer**? - **M365 Home/Consumer** = `hotmail.com`, `outlook.com`, `live.com` - **M365 Business** = Work/School account (Exchange Online) ### 1) Privacy / keys (important) - **No third-party API key required.** - Auth is done via **your own Microsoft login** (device code flow). - Tokens are stored **locally per profile** on the machine running OpenClaw. - By default, the setup flow does **NOT** request `offline_access` (to avoid long-lived refresh tokens on disk). Use `--offline` only if you explicitly want background refresh. ### 2) You need an **App (client) ID** You must pass `--clientId`. - **Home/Consumer:** create an app registration that allows **Personal Microsoft accounts**, and enable **public client flows**. - **Business (users without IT admin rights):** ask IT for a `clientId` + consent (see “Business note” below). ### 3) One-command setup (recommended) ```bash # Consumer / home accounts (hotmail.com / outlook.com) node skills/m365-calendar/scripts/setup.mjs \ --profile home \ --tenant consumers \ --email [REDACTED_SECRET] \ --clientId <YOUR_APP_CLIENT_ID> \ --tz Europe/Vienna # Business / work accounts node skills/m365-calendar/scripts/setup.mjs \ --profile business \ --tenant organizations \ --email [REDACTED_SECRET] \ --clientId <IT_PROVIDED_CLIENT_ID> \ --tz Europe/Vienna ``` ### 2) Use ```bash node skills/m365-calendar/scripts/list.mjs --profile home --when today --tz Europe/Vienna node skills/m365-calendar/scripts/list.mjs --profile home --when tomorrow --tz Europe/Vienna node skills/m365-calendar/scripts/search.mjs --profile home --when tomorrow --tz Europe/Vienna --query "Mittagessen" ``` 2) List remaining events today (Europe/Vienna): ```bash node skills/m365-calendar/scripts/list.mjs --profile tom-business --when today --tz Europe/Vienna ``` 3) Search and show attendee responses: ```bash node skills/m365-calendar/scripts/search.mjs --profile tom-business --query "Mittagessen" --when tomorrow --tz Europe/Vienna node skills/m365-calendar/scripts/get-event.mjs --profile tom-business --id <EVENT_ID> --tz Europe/Vienna ``` 4) Move an event to a new time: ```bash node skills/m365-calendar/scripts/move-event.mjs --profile tom-business --id <EVENT_ID> \ --start "2026-02-19T12:30" --end "2026-02-19T13:00" --tz Europe/Vienna ``` ## Operational workflow (recommended) When the user asks to change a meeting: 1) Identify the event deterministically (search by day-range + subject; confirm ID). 2) Read the event and report attendee response statuses. 3) Patch start/end. 4) Re-read the event and confirm the final start/end + any response resets. ## Notes on Business vs Home (Consumer) - Use `--tenant organizations` for work/school accounts (most “business” tenants). - Use `--tenant consumers` for hotmail/outlook.com personal accounts. - Use `--tenant common` only if you explicitly want one profile that can log into either type. ### Consumer (hotmail/outlook.com) requirements Your app registration must: - Allow **personal Microsoft accounts** - Enable **public client flows** (otherwise device-code can fail with errors like AADSTS70002) ### Business note (users without IT admin rights) Many tenants block: - creating app registrations as a normal user - user consent to new apps In that case the skill can still work for Business accounts, but only if your **IT/SysAdmin** provides a `clientId` for an app registration configured with: - Account type: organizational accounts (or org+personal) - Delegated Microsoft Graph permissions: `Calendars.Read`, `Calendars.ReadWrite`, `offline_access` - **Public client flows enabled** (Device Code) - (Often required) **Admin consent granted** for the above permissions If you don’t get such a `clientId`/consent from IT, you can still use the skill with a **Consumer** account (hotmail/outlook.com), but your Business calendar will remain blocked. If silent token acquisition fails, re-run `setup.mjs` for that profile.

path: package.json
content: {
  "name": "m365-calendar",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "description": "OpenClaw skill: Microsoft Graph calendar automation (M365 business + consumer).",
  "dependencies": {
    "@azure/msal-node": "^5.0.4"
  }
}


path: references/config.md
content: # Config + storage

This skill keeps secrets out of git by default.

## Files

For each `--profile <name>` it stores:

- `~/.openclaw/secrets/m365-calendar/<profile>.json`
  - `clientId` (public client)
  - `tenant` (`organizations|consumers|common` or explicit tenantId)
  - `email` (optional; informational)
  - `scopes` (calendar delegated scopes)
- `~/.openclaw/secrets/m365-calendar/<profile>-token-cache.json`
  - MSAL token cache (refresh token, access token, etc.)

## Why not workspace/secrets?

- Skills should be shareable (e.g. Stefan) without sharing Tom’s tokens.
- Storing under `~/.openclaw/secrets/` makes it per-machine.

## Tenant values

- `organizations` → work/school tenants
- `consumers` → personal Microsoft accounts (hotmail.com, outlook.com)
- `common` → both (interactive login decides)

Graph supports `Prefer: outlook.timezone="Europe/Vienna"` so returned times match local expectations.


path: scripts/_graph.mjs
content: import fs from 'node:fs';
import { PublicClientApplication } from '@azure/msal-node';
import { profilePaths, readJson } from './_lib.mjs';

export async function getAccessToken(profile, scopes = ['Calendars.Read']) {
  const { cfgPath, cachePath } = profilePaths(profile);
  if (!fs.existsSync(cfgPath)) throw new Error(`Missing profile config: ${cfgPath} (run auth-devicecode.mjs)`);
  if (!fs.existsSync(cachePath)) throw new Error(`Missing token cache: ${cachePath} (run auth-devicecode.mjs)`);

  const cfg = readJson(cfgPath);
  const cacheText = fs.readFileSync(cachePath, 'utf8');

  // Fallback: if cache is a raw OAuth token JSON (not MSAL cache), use it directly.
  // Raw token file shape: { access_token, expires_in, scope, token_type, refresh_token?, obtainedAt, ... }
  try {
    const raw = JSON.parse(cacheText);
    if (raw && typeof raw === 'object' && raw.access_token) {
      return raw.access_token;
    }
  } catch {
    // not json or not raw token
  }

  // Default: MSAL token cache
  let cache = cacheText;
  const pca = new PublicClientApplication({
    auth: {
      clientId: cfg.clientId,
      authority: `https://login.microsoftonline.com/${cfg.tenant}`,
    },
    cache: {
      cachePlugin: {
        beforeCacheAccess: async (ctx) => ctx.tokenCache.deserialize(cache),
        afterCacheAccess: async (ctx) => {
          if (ctx.cacheHasChanged) {
            cache = ctx.tokenCache.serialize();
            fs.writeFileSync(cachePath, cache, 'utf8');
          }
        },
      },
    },
  });

  const accounts = await pca.getTokenCache().getAllAccounts();
  if (!accounts.length) throw new Error(`No accounts in token cache for profile ${profile} (re-run auth-devicecode.mjs)`);

  const res = await pca.acquireTokenSilent({
    account: accounts[0],
    scopes,
  });

  return res.accessToken;
}

export async function graphFetch(url, { method = 'GET', headers = {}, body, token } = {}) {
  const res = await fetch(url, {
    method,
    headers: {
      ...headers,
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    body,
  });

  const text = await res.text();
  let data;
  try {
    data = text ? JSON.parse(text) : null;
  } catch {
    data = text;
  }
  if (!res.ok) {
    const msg = typeof data === 'object' ? JSON.stringify(data) : String(data);
    throw new Error(`Graph error ${res.status}: ${msg.slice(0, 800)}`);
  }
  return data;
}


path: scripts/_lib.mjs
content: import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';

export function secretsDir() {
  return path.join(os.homedir(), '.openclaw', 'secrets', 'm365-calendar');
}

export function ensureSecretsDir() {
  const dir = secretsDir();
  fs.mkdirSync(dir, { recursive: true });
  return dir;
}

export function profilePaths(profile) {
  const dir = ensureSecretsDir();
  return {
    cfgPath: path.join(dir, `${profile}.json`),
    cachePath: path.join(dir, `${profile}-token-cache.json`),
  };
}

export function readJson(p) {
  return JSON.parse(fs.readFileSync(p, 'utf8'));
}

export function writeJson(p, obj) {
  fs.writeFileSync(p, JSON.stringify(obj, null, 2) + '\n', 'utf8');
}

export function mustGetArg(name) {
  const idx = process.argv.indexOf(`--${name}`);
  if (idx === -1 || !process.argv[idx + 1]) {
    throw new Error(`Missing --${name}`);
  }
  return process.argv[idx + 1];
}

export function getArg(name, def = undefined) {
  const idx = process.argv.indexOf(`--${name}`);
  if (idx === -1) return def;
  return process.argv[idx + 1] ?? def;
}

export function hasFlag(name) {
  return process.argv.includes(`--${name}`);
}

export function tzPreferHeader(tz) {
  return tz ? { Prefer: `outlook.timezone=\"${tz}\"` } : {};
}

export function dayRangeIsoLocal(offsetDays) {
  const now = new Date();
  const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offsetDays, 0, 0, 0);
  const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offsetDays + 1, 0, 0, 0);
  return { start, end };
}

export function whenToRange(when) {
  if (when === 'today') {
    const now = new Date();
    const { end } = dayRangeIsoLocal(0);
    return { start: now, end };
  }
  if (when === 'tomorrow') {
    const { start, end } = dayRangeIsoLocal(1);
    return { start, end };
  }
  throw new Error(`Unsupported --when ${when} (use today|tomorrow)`);
}


path: scripts/auth-devicecode.mjs
content: #!/usr/bin/env node
import fs from 'node:fs';
import { PublicClientApplication } from '@azure/msal-node';
import { ensureSecretsDir, profilePaths, writeJson, getArg, mustGetArg } from './_lib.mjs';

/**
 * Device code auth for delegated Graph calendar scopes.
 *
 * Examples:
 *  node skills/m365-calendar/scripts/auth-devicecode.mjs --profile business --tenant organizations --email [REDACTED_SECRET]
 *  node skills/m365-calendar/scripts/auth-devicecode.mjs --profile home --tenant consumers --email [REDACTED_SECRET]
 *
 * Optional:
 *  --clientId <appId>  (use an app registration that allows personal Microsoft accounts for consumers)
 */

const profile = mustGetArg('profile');
const tenant = getArg('tenant', 'organizations'); // organizations|consumers|common|<tenantId>
const email = getArg('email', undefined);
const clientId = mustGetArg('clientId');

// Minimum scopes for our use-cases
const wantOffline = process.argv.includes('--offline');

const scopes = [
  'Calendars.Read',
  'Calendars.ReadWrite',
  // offline_access is optional; without it we avoid long-lived refresh tokens on disk.
  ...(wantOffline ? ['offline_access'] : []),
  'openid',
  'profile',
  'email',
];

ensureSecretsDir();
const { cfgPath, cachePath } = profilePaths(profile);

let cache = fs.existsSync(cachePath) ? fs.readFileSync(cachePath, 'utf8') : '';

const pca = new PublicClientApplication({
  auth: {
    clientId,
    authority: `https://login.microsoftonline.com/${tenant}`,
  },
  cache: {
    cachePlugin: {
      beforeCacheAccess: async (ctx) => {
        if (cache) ctx.tokenCache.deserialize(cache);
      },
      afterCacheAccess: async (ctx) => {
        if (ctx.cacheHasChanged) {
          cache = ctx.tokenCache.serialize();
          fs.writeFileSync(cachePath, cache, 'utf8');
        }
      },
    },
  },
});

const result = await pca.acquireTokenByDeviceCode({
  scopes,
  deviceCodeCallback: (resp) => {
    // For consumers, we saw MSAL returning undefined fields; build our own message.
    const uri = resp.verificationUri || resp.verification_uri || 'https://microsoft.com/devicelogin';
    const code = resp.userCode || resp.user_code;
    console.log(`Open ${uri} and enter code: ${code}`);
  },
});

writeJson(cfgPath, {
  clientId,
  tenant,
  email,
  scopes: wantOffline
    ? ['Calendars.Read', 'Calendars.ReadWrite', 'offline_access']
    : ['Calendars.Read', 'Calendars.ReadWrite'],
  authFlow: wantOffline ? 'device_code_delegated_offline' : 'device_code_delegated',
  createdAt: new Date().toISOString(),
  notes: wantOffline
    ? 'offline_access enabled (refresh tokens may be stored in local token cache). Do not commit secrets.'
    : 'offline_access disabled (no refresh token expected). Do not commit secrets.',
});

console.log(`OK: authenticated profile=${profile} tenant=${tenant} user=${result?.account?.username || 'unknown'}`);


path: scripts/get-event.mjs
content: #!/usr/bin/env node
import { getArg, mustGetArg, tzPreferHeader } from './_lib.mjs';
import { getAccessToken, graphFetch } from './_graph.mjs';

const profile = mustGetArg('profile');
const id = mustGetArg('id');
const tz = getArg('tz', 'Europe/Vienna');

const url = `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(id)}?$select=subject,start,end,attendees,organizer,location`;
const token = await getAccessToken(profile, ['Calendars.Read']);
const e = await graphFetch(url, { token, headers: { ...tzPreferHeader(tz) } });

console.log(`subject: ${e.subject}`);
console.log(`start: ${e.start?.dateTime} (${e.start?.timeZone})`);
console.log(`end:   ${e.end?.dateTime} (${e.end?.timeZone})`);

const attendees = (e.attendees || []).map(a => ({
  name: a.emailAddress?.name,
  address: a.emailAddress?.address,
  type: a.type,
  response: a.status?.response,
  time: a.status?.time,
}));

console.log('attendees:', JSON.stringify(attendees, null, 2));


path: scripts/import-raw-token.mjs
content: #!/usr/bin/env node
/**
 * Import a raw OAuth token JSON (from device code / token endpoint) into a skill profile.
 *
 * This is a fallback when MSAL device-code flow is blocked/broken by tenant policy.
 *
 * Usage:
 *  node skills/m365-calendar/scripts/import-raw-token.mjs --profile tom-home --file ~/.openclaw/secrets/m365-calendar/tom-home-raw-token.json
 */

import fs from 'node:fs';
import { mustGetArg, profilePaths, ensureSecretsDir, writeJson } from './_lib.mjs';

const profile = mustGetArg('profile');
const file = mustGetArg('file');

ensureSecretsDir();
const { cfgPath, cachePath } = profilePaths(profile);

const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!raw.access_token) throw new Error('Raw token file missing access_token');

// Store as a simple JSON cache format that _graph.mjs can consume when present.
fs.writeFileSync(cachePath, JSON.stringify(raw, null, 2) + '\n', 'utf8');

writeJson(cfgPath, {
  clientId: raw.client_id || null,
  tenant: raw.tenant || 'consumers',
  email: raw.email || null,
  scopes: raw.scope ? String(raw.scope).split(' ') : [],
  authFlow: 'device_code_raw_import',
  createdAt: new Date().toISOString(),
  notes: 'Imported raw token JSON. Prefer MSAL cache when possible.',
});

console.log(`OK: imported raw token into profile=${profile}`);


path: scripts/list.mjs
content: #!/usr/bin/env node
import { getArg, mustGetArg, whenToRange, tzPreferHeader } from './_lib.mjs';
import { getAccessToken, graphFetch } from './_graph.mjs';

const profile = mustGetArg('profile');
const when = getArg('when', 'today'); // today|tomorrow
const tz = getArg('tz', 'Europe/Vienna');
const top = Number(getArg('top', '50'));

const { start, end } = whenToRange(when);

const qs = new URLSearchParams({
  startDateTime: start.toISOString(),
  endDateTime: end.toISOString(),
  '$top': String(top),
  '$orderby': 'start/dateTime',
  '$select': 'id,subject,start,end,location,organizer,attendees,responseStatus',
});

const url = `https://graph.microsoft.com/v1.0/me/calendarView?${qs.toString()}`;
const token = await getAccessToken(profile, ['Calendars.Read']);
const data = await graphFetch(url, { token, headers: { ...tzPreferHeader(tz) } });

for (const e of data.value || []) {
  console.log(`${e.start?.dateTime} → ${e.end?.dateTime} | ${e.subject} | id=${e.id}`);
}


path: scripts/move-event.mjs
content: #!/usr/bin/env node
import { getArg, mustGetArg, tzPreferHeader } from './_lib.mjs';
import { getAccessToken, graphFetch } from './_graph.mjs';

const profile = mustGetArg('profile');
const id = mustGetArg('id');
const start = mustGetArg('start'); // YYYY-MM-DDTHH:mm
const end = mustGetArg('end');
const tz = getArg('tz', 'Europe/Vienna');

const patch = {
  start: { dateTime: start, timeZone: tz },
  end: { dateTime: end, timeZone: tz },
};

const url = `https://graph.microsoft.com/v1.0/me/events/${encodeURIComponent(id)}`;
const token = await getAccessToken(profile, ['Calendars.ReadWrite']);
const updated = await graphFetch(url, {
  method: 'PATCH',
  token,
  headers: {
    'Content-Type': 'application/json',
    ...tzPreferHeader(tz),
  },
  body: JSON.stringify(patch),
});

console.log(`OK: moved event ${updated.id}`);
console.log(`start: ${updated.start?.dateTime} (${updated.start?.timeZone})`);
console.log(`end:   ${updated.end?.dateTime} (${updated.end?.timeZone})`);


path: scripts/search.mjs
content: #!/usr/bin/env node
import { getArg, mustGetArg, whenToRange, tzPreferHeader } from './_lib.mjs';
import { getAccessToken, graphFetch } from './_graph.mjs';

const profile = mustGetArg('profile');
const query = mustGetArg('query');
const when = getArg('when', 'tomorrow');
const tz = getArg('tz', 'Europe/Vienna');

const { start, end } = whenToRange(when);

const qs = new URLSearchParams({
  startDateTime: start.toISOString(),
  endDateTime: end.toISOString(),
  '$top': '50',
  '$orderby': 'start/dateTime',
  '$select': 'id,subject,start,end,attendees',
});

const url = `https://graph.microsoft.com/v1.0/me/calendarView?${qs.toString()}`;
const token = await getAccessToken(profile, ['Calendars.Read']);
const data = await graphFetch(url, { token, headers: { ...tzPreferHeader(tz) } });

const q = query.toLowerCase();
const hits = (data.value || []).filter(e => (e.subject || '').toLowerCase().includes(q));
for (const e of hits) {
  console.log(`${e.start?.dateTime} → ${e.end?.dateTime} | ${e.subject} | id=${e.id}`);
}
if (!hits.length) process.exitCode = 2;


path: scripts/setup.mjs
content: #!/usr/bin/env node
/**
 * One-command setup wrapper.
 *
 * Example:
 *  node skills/m365-calendar/scripts/setup.mjs --profile tom-home --tenant consumers --email [REDACTED_SECRET] --tz Europe/Vienna
 */

import { spawnSync } from 'node:child_process';
import { getArg, mustGetArg } from './_lib.mjs';

const profile = mustGetArg('profile');
const tenant = getArg('tenant', 'organizations');
const email = getArg('email', undefined);
const tz = getArg('tz', 'Europe/Vienna');

const clientId = getArg('clientId', undefined);

const authArgs = [
  'skills/m365-calendar/scripts/auth-devicecode.mjs',
  '--profile', profile,
  '--tenant', tenant,
  '--clientId', clientId || '',
];
if (!clientId) {
  console.error('Missing --clientId (required for deterministic setup; use your App registration Application (client) ID).');
  process.exit(2);
}
if (email) authArgs.push('--email', email);

const r = spawnSync('node', authArgs, { stdio: 'inherit' });
if (r.status !== 0) process.exit(r.status ?? 1);

console.log('\nNext: quick smoke tests');
console.log(`  node skills/m365-calendar/scripts/list.mjs --profile ${profile} --when today --tz ${tz}`);
console.log(`  node skills/m365-calendar/scripts/list.mjs --profile ${profile} --when tomorrow --tz ${tz}`);
console.log(`  node skills/m365-calendar/scripts/search.mjs --profile ${profile} --when tomorrow --tz ${tz} --query "Mittagessen"`);


Security scan verdicts — ClawScan: clean | static analysis:  | VirusTotal: suspicious | SkillSpector: clean (LOW)