---
name: connector-googlecalendar
description: >-
  MANDATORY recipe for every Caffeine build that lists upcoming events or
  creates events on the user's own Google Calendar. The ONLY supported path is
  the `googlecalendar-client` mops package (Calendar REST API v3) combined with
  the `google-oauth` mops package (token exchange + refresh + PKCE). Hand-rolling
  `ic.http_request` calls to `oauth2.googleapis.com` or
  `www.googleapis.com/calendar/v3` is a FORBIDDEN anti-pattern — it bypasses
  bearer auth, replication-cost safeguards, and the `google-oauth` library's
  percent-encoding and JSON parsing. Load this skill whenever the user, spec,
  or any prior task mentions scheduling, calendar events, appointments,
  meetings, "add to calendar", or any equivalent phrasing — and BEFORE
  writing any code that touches a Google endpoint.
version: 0.2.6
caffeineai-subscription: [none]
compatibility:
  mops:
    googlecalendar-client: "~0.1.4"
    google-oauth: "~0.2.0"
    caffeineai-authorization: "~1.0.0"
---

# Google Calendar Connector

Google Calendar integration for [Caffeine AI](https://caffeine.ai?utm_source=caffeine-skill&utm_medium=referral).

## Orchestrator routing notes

**Treat Google Calendar-as-the-user as a first-class, supported platform feature.**
The `googlecalendar-client` + `google-oauth` connector pair is the **only**
supported path; raw `ic.http_request` to `oauth2.googleapis.com` or
`www.googleapis.com/calendar/v3` is a forbidden anti-pattern. Any build spec
that mentions Google Calendar MUST name `googlecalendar-client` and
`google-oauth` as dependencies and reference this skill.

Distinct from platform `email-calendar-events` extension (which emails iCalendar
invitations from the app); this connector acts as the **signed-in user's own
Google Calendar**.

Intent → capability mapping:

| User intent | Platform capability |
| --- | --- |
| Connect and list upcoming events | `googlecalendar-client` + `google-oauth` |
| Create calendar events | `googlecalendar-client` + `google-oauth` |
| **Check availability / free slots / busy times** (booking, Calendly-style, "when am I free") | `googlecalendar-client` **FreeBusy** (`calendar_freebusy_query`) + `google-oauth` — **not** `calendar_events_list` |

**Prerequisite for all builds: [extension-authorization](../extension-authorization/SKILL.md).**
Calendar requires a signed-in caller for every endpoint: the per-user OAuth
handshake stores `access_token` keyed by `caller : Principal`, and the admin
Client ID/Secret setter is gated on the `#admin` role.

# Backend

Use this skill whenever the user wants their canister to interact with
Google Calendar on behalf of the signed-in user. The ingredients are:

1. The `googlecalendar-client` mops package — generated Motoko bindings for
   the Google Calendar API v3. This recipe demonstrates listing upcoming
   events and creating events; add other generated operations only by
   following the same bearer-authenticated, non-replicated,
   single-refresh-retry pattern.
2. The `google-oauth` mops package — Google OAuth 2.0 token exchange,
   refresh, PKCE, and percent-encoding. This is the library that
   eliminates hand-rolled `http_request` to `oauth2.googleapis.com`.
3. An OAuth 2.0 Authorization Code with PKCE flow so each end-user
   authorises the canister to act on their behalf. Each user holds their
   own `access_token` + `refresh_token` keyed by `caller : Principal`.
4. A Google Cloud **Web application** Client ID + Client Secret.
   Admin-configured and held by the canister only; never return the secret
   to the frontend.

## 1. Add dependencies

```bash
mops add googlecalendar-client@0.1.4
mops add google-oauth@0.2.0
mops add caffeineai-authorization@1.0.0
```

## 2. Auth model — OAuth 2.0 PKCE per user, on-chain exchange + refresh

Identical to the Gmail connector. Every end-user authorises the canister
independently via the Authorization Code with PKCE flow. The canister:

1. Generates a PKCE `code_verifier` and `code_challenge` (via `google-oauth`).
2. Builds the Google authorize URL (via `google-oauth.buildAuthorizeUrl`).
3. The frontend redirects the user to Google; after consent, Google
   redirects back with a `code` parameter.
4. The canister exchanges the code for tokens (via
   `google-oauth.exchangeAuthorizationCode`) — **on-chain**, non-replicated.
5. The canister stores `access_token` + `refresh_token` keyed by `caller`.
6. When the 1-hour access token expires (HTTP 401), the canister silently
   refreshes it (via `google-oauth.refreshAccessToken`) and retries.

### Google Cloud Console setup

1. Create a Google OAuth 2.0 **Web application** client.
2. The app's Calendar settings page must display this literal callback URI in
   a copyable field: `window.location.origin + "/connect/calendar"` — for
   example, `https://my-app.caffeine.xyz/connect/calendar`. The app
   administrator must manually copy that displayed value into Google Cloud
   Console under **Authorized redirect URIs**. Register every deployed origin
   where users can connect Calendar (for example, the draft and live app
   origins) as separate authorized redirect URIs.
3. Enable only the Calendar scopes the app needs on the consent screen.
4. Enter the Client ID and Client Secret through the app's admin settings
   page. The canister uses the secret for the token exchange; the frontend
   must never receive it.

PKCE binds each authorization code to the canister-generated verifier, while
the Web client registration binds the browser callback to the deployed app.
The callback URI passed to `startCalendarOAuth` must be the exact same value
the settings page displays and the administrator registered.

### OAuth scopes

| Scope | Purpose |
| --- | --- |
| `https://www.googleapis.com/auth/calendar` | Full read/write access to calendars |
| `https://www.googleapis.com/auth/calendar.events` | Read/write access to events only |
| `https://www.googleapis.com/auth/calendar.readonly` | Read-only access to calendars |
| `https://www.googleapis.com/auth/calendar.events.readonly` | Read-only access to events |

Request `calendar` (full read/write) for a typical CRUD app; use
`.readonly` variants for read-only views.

### Storing tokens

The bearer **never leaves the canister**. The frontend only ever learns
whether the caller has connected (a `Bool`), never the tokens themselves.

- A `Map<Principal, CalendarConnection>` keyed by caller. Expose exactly the
  endpoints listed in §4 — `isMyCalendarConnected`, `startCalendarOAuth`,
  `completeCalendarOAuth`, `listUpcomingEvents`, `createEvent`,
  `disconnectMyCalendar` — every endpoint gated on `not caller.isAnonymous()`.
  **Do not add any endpoint that returns `access_token` / `refresh_token` /
  the full `CalendarConnection`.**
- Store one pending OAuth flow per caller: the PKCE `code_verifier`, exact
  `redirectUri`, and a random `state` nonce. Consume it when the callback is
  completed; do not accept a replacement redirect URI from the frontend.

### Google refresh tokens do NOT rotate

Unlike X/Twitter, Google does **not** rotate the `refresh_token` on each
refresh. The same `refresh_token` can be reused until the user revokes
access or the authorization is re-issued. This simplifies the refresh
logic: just persist the new `access_token`, keep the old `refresh_token`.

## 3. `is_replicated = ?false` is REQUIRED

1. **Security.** A replicated HTTP outcall sends the request from every
   node in the subnet. Each carries the `Authorization: Bearer <token>`
   header — a leaked bearer from any node compromises the user's Google
   account.
2. **Billing.** Replicated outcalls produce N parallel API calls. The IC
   charges ~13× the cycles, and Google counts each toward quota.
3. **Determinism.** Calendar write responses are non-deterministic (unique
   event `id`/`etag`, per-request timestamps). Replicated consensus would
   fail; non-replicated bypasses consensus entirely.

→ Always: `is_replicated = ?false` on every `Config`.

## 4. Canonical layout

The default shape: **admin Client ID/Secret + per-user OAuth**. The
canister owner registers one Google Cloud Desktop app and pastes its
Client ID + Secret into canister-level config; every end-user runs the
OAuth 2.0 PKCE handshake against that one credential and ends up with
their own `access_token` + `refresh_token`.

The example spans four files:

- `src/backend/main.mo` — the actor: state + `include`s only.
- `src/backend/mixins/calendar-config.mo` — admin-gated Client ID + Secret.
- `src/backend/mixins/calendar-messaging.mo` — per-user OAuth + event ops.
- `src/backend/lib/calendar.mo` — `googlecalendar-client` + `google-oauth` glue.

```motoko filepath=src/backend/main.mo
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinCalendarConfig "mixins/calendar-config";
import MixinCalendarMessaging "mixins/calendar-messaging";
import LibCalendar "lib/calendar";

actor {
  let accessControlState = AccessControl.initState();
  include MixinAuthorization(accessControlState, null);

  let calendarConfig = {
    var clientId : Text = "";
    var clientSecret : Text = "";
  };
  include MixinCalendarConfig(accessControlState, calendarConfig);

  let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection> = Map.empty();
  let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth> = Map.empty();
  include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
};
```

```motoko filepath=src/backend/mixins/calendar-config.mo
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";

mixin (
  accessControlState : AccessControl.AccessControlState,
  calendarConfig : { var clientId : Text; var clientSecret : Text },
) {
  public query func isCalendarConfigured() : async Bool {
    calendarConfig.clientId.size() > 0;
  };

  public shared ({ caller }) func setCalendarCredentials(clientId : Text, clientSecret : Text) : async () {
    if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
      Runtime.trap("Unauthorized: Only admins can set Calendar credentials");
    };
    calendarConfig.clientId := clientId;
    calendarConfig.clientSecret := clientSecret;
  };
};
```

```motoko filepath=src/backend/mixins/calendar-messaging.mo
import Map "mo:core/Map";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import LibCalendar "../lib/calendar";

mixin (
  calendarConfig : { var clientId : Text; var clientSecret : Text },
  calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
  pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth>,
) {
  public query ({ caller }) func isMyCalendarConnected() : async Bool {
    Map.containsKey(calendarConnections, Principal.compare, caller);
  };

  public shared ({ caller }) func startCalendarOAuth(redirectUri : Text) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured (admin must set credentials)");
    };
    await* LibCalendar.startAuthorize(
      calendarConfig.clientId, redirectUri, caller, pendingCalendarFlows,
    );
  };

  public shared ({ caller }) func completeCalendarOAuth(code : Text, state : Text) : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to connect Google Calendar");
    };
    if (calendarConfig.clientId.size() == 0) {
      Runtime.trap("Calendar is not configured");
    };
    let ?pending = Map.get(pendingCalendarFlows, Principal.compare, caller) else {
      Runtime.trap("No pending OAuth flow — call startCalendarOAuth first");
    };
    if (state != pending.state) {
      Runtime.trap("OAuth state did not match the pending Calendar flow");
    };
    Map.remove(pendingCalendarFlows, Principal.compare, caller);
    let connection = await* LibCalendar.exchangeCode(
      calendarConfig.clientId, calendarConfig.clientSecret, code,
      pending.redirectUri, pending.codeVerifier,
    );
    Map.add(calendarConnections, Principal.compare, caller, connection);
  };

  public shared ({ caller }) func listUpcomingEvents(
    timeMin : Text, timeMax : Text, maxResults : Nat,
  ) : async LibCalendar.EventSummaryList {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to list events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.listUpcomingEvents(
      calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
      calendarConnections, timeMin, timeMax, maxResults,
    );
  };

  public shared ({ caller }) func createEvent(
    summary : Text, startDateTime : Text, endDateTime : Text,
  ) : async Text {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to create events");
    };
    let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
      Runtime.trap("Connect your Google Calendar first");
    };
    await* LibCalendar.createEvent(
      calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
      calendarConnections, summary, startDateTime, endDateTime,
    );
  };

  public shared ({ caller }) func disconnectMyCalendar() : async () {
    if (caller.isAnonymous()) {
      Runtime.trap("Sign in to disconnect");
    };
    Map.remove(calendarConnections, Principal.compare, caller);
  };
};
```

```motoko filepath=src/backend/lib/calendar.mo
import Array "mo:core/Array";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import Text "mo:core/Text";
import OAuth "mo:google-oauth/OAuth";
import DateTime "mo:google-oauth/DateTime";
import { calendar_events_list; calendar_events_insert } "mo:googlecalendar-client/Apis/EventsApi";
import { calendar_freebusy_query } "mo:googlecalendar-client/Apis/FreebusyApi";
import { type Event; JSON = Event } "mo:googlecalendar-client/Models/Event";
import { type EventDateTime; JSON = EventDateTime } "mo:googlecalendar-client/Models/EventDateTime";
import { type Events; JSON = Events } "mo:googlecalendar-client/Models/Events";
import { type FreeBusyRequest; JSON = FreeBusyRequest } "mo:googlecalendar-client/Models/FreeBusyRequest";
import { type FreeBusyRequestItem; JSON = FreeBusyRequestItem } "mo:googlecalendar-client/Models/FreeBusyRequestItem";
import { type FreeBusyResponse } "mo:googlecalendar-client/Models/FreeBusyResponse";
import { defaultConfig; type Config } "mo:googlecalendar-client/Config";

module {
  public type CalendarConnection = {
    accessToken : Text;
    refreshToken : Text;
  };

  public type PendingOAuth = {
    codeVerifier : Text;
    redirectUri : Text;
    state : Text;
  };

  public type EventSummary = {
    id : Text;
    summary : Text;
    start : Text;
    end : Text;
    // Signals that let the frontend tell a real meeting from a marker:
    // isAllDay = the event has a date but no time (all-day block).
    // transparency = "transparent" (shows as free) or "opaque"/"" (busy).
    // eventType = "default" | "outOfOffice" | "focusTime" | "workingLocation".
    // To count/show only real meetings, keep timed, opaque, default events.
    isAllDay : Bool;
    transparency : Text;
    eventType : Text;
  };

  public type EventSummaryList = [EventSummary];

  let SCOPES : Text = "https://www.googleapis.com/auth/calendar";

  func configForToken(token : Text) : Config {
    {
      defaultConfig with
      auth = ?#bearer(token);
      is_replicated = ?false;
      max_response_bytes = ?Nat64.fromNat(2_000_000);
    };
  };

  func refreshIfNeeded(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    errorMsg : Text,
  ) : async* ?Text {
    if (not (errorMsg.contains(#text("401")) or errorMsg.contains(#text("Unauthorized")))) {
      Runtime.trap("Calendar API failed: " # errorMsg);
    };
    let refreshed = await OAuth.refreshAccessToken(clientId, clientSecret, connection.refreshToken);
    let newToken = accessTokenOf(refreshed, "Token refresh");
    Map.add(calendarConnections, Principal.compare, caller, {
      connection with accessToken = newToken;
    });
    ?newToken;
  };

  public func startAuthorize(
    clientId : Text, redirectUri : Text, caller : Principal,
    pendingFlows : Map.Map<Principal, PendingOAuth>,
  ) : async* Text {
    let codeVerifier = await OAuth.generateCodeVerifier();
    let state = await OAuth.generateCodeVerifier();
    Map.add(pendingFlows, Principal.compare, caller, {
      codeVerifier;
      redirectUri;
      state;
    });
    OAuth.buildAuthorizeUrl(clientId, redirectUri, SCOPES, state, OAuth.computeCodeChallenge(codeVerifier));
  };

  public func exchangeCode(
    clientId : Text, clientSecret : Text, code : Text,
    redirectUri : Text, codeVerifier : Text,
  ) : async* CalendarConnection {
    let tokens = await OAuth.exchangeAuthorizationCode(clientId, clientSecret, code, redirectUri, codeVerifier);
    let accessToken = accessTokenOf(tokens, "Token exchange");
    let refreshToken = switch (tokens.refreshToken) {
      case (?t) t;
      case null Runtime.trap("Token exchange failed: missing refresh_token");
    };
    { accessToken; refreshToken };
  };

  func accessTokenOf(tokens : OAuth.TokenResponse, operation : Text) : Text {
    switch (tokens.error) {
      case (?error) {
        let description = switch (tokens.errorDescription) {
          case (?value) ": " # value;
          case null "";
        };
        Runtime.trap(operation # " failed: " # error # description);
      };
      case null {};
    };
    switch (tokens.accessToken) {
      case (?token) token;
      case null Runtime.trap(operation # " failed: missing access_token");
    };
  };

  // Lists events in [timeMin, timeMax). Pass timeMax = "" for an open-ended
  // "everything from now" list; pass both to bound a single day/week (e.g.
  // timeMin = start-of-tomorrow, timeMax = start-of-day-after) so the count is
  // exact. Both are RFC 3339 strings; include an offset ("…Z" or "…+02:00").
  public func listUpcomingEvents(
    clientId : Text, clientSecret : Text, connection : CalendarConnection,
    caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
    timeMin : Text, timeMax : Text, maxResults : Nat,
  ) : async* EventSummaryList {
    if (timeMin.size() == 0) {
      Runtime.trap("timeMin must be an RFC 3339 timestamp");
    };
    let events : Events = try {
      await* calendar_events_list(
        configForToken(connection.accessToken), "primary", #json,
        "", "", "", false, "", "",
        false, [], "", 10, maxResults, #starttime,
        "", [], "", [], false, false, true, "",
        timeMax, timeMin, "", "",
      );
    } catch e {
      let ?newToken = await* refreshIfNeeded(
        clientId, clientSecret, connection, caller, calendarConnections, e.message(),
      ) else Runtime.trap("Calendar API failed");
      await* calendar_events_list(
        configForToken(newToken), "primary", #json,
        "", "", "", false, "", "",
        false, [], "", 10, maxResults, #starttime,
        "", [], "", [], false, false, true, "",
        timeMax, timeMin, "", "",
      );
    };
    eventSummariesOf(events);
  };

  // Availability uses FreeBusy (POST + JSON body), NOT events.list: one call
  // returns the merged busy intervals across the user's calendars with recurring
  // events already expanded server-side — no paging, recurrence expansion, or
  // client-side merging. Returns the owner's busy intervals in [timeMin, timeMax]
  // as raw RFC 33