Google Calendar Connector
Google Calendar integration for Caffeine AI.
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.
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:
- 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.
- 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.
- 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.
- 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
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:
- Generates a PKCE
code_verifier and code_challenge (via google-oauth).
- Builds the Google authorize URL (via
google-oauth.buildAuthorizeUrl).
- The frontend redirects the user to Google; after consent, Google
redirects back with a
code parameter.
- The canister exchanges the code for tokens (via
google-oauth.exchangeAuthorizationCode) — on-chain, non-replicated.
- The canister stores
access_token + refresh_token keyed by caller.
- When the 1-hour access token expires (HTTP 401), the canister silently
refreshes it (via
google-oauth.refreshAccessToken) and retries.
Google Cloud Console setup
- Create a Google OAuth 2.0 Web application client.
- 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.
- Enable only the Calendar scopes the app needs on the consent screen.
- 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
- 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.
- Billing. Replicated outcalls produce N parallel API calls. The IC
charges ~13× the cycles, and Google counts each toward quota.
- 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 + includes 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.
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);
};
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;
};
};
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);
};
};
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 3339 (start, end) pairs; timeMin/timeMax are UTC "…Z" strings.
// Single-refresh-on-401 retry.
public func busyTimes(
clientId : Text, clientSecret : Text, connection : CalendarConnection,
caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
timeMin : Text, timeMax : Text,
) : async* [(Text, Text)] {
let request : FreeBusyRequest = {
FreeBusyRequest.init {} with
timeMin = ?timeMin;
timeMax = ?timeMax;
items = ?[{ FreeBusyRequestItem.init {} with id = ?"primary" }];
};
let response : FreeBusyResponse = try {
await* calendar_freebusy_query(
configForToken(connection.accessToken), #json, "", "", "", false, "", "", request,
);
} catch e {
let ?newToken = await* refreshIfNeeded(
clientId, clientSecret, connection, caller, calendarConnections, e.message(),
) else Runtime.trap("Calendar API failed");
await* calendar_freebusy_query(
configForToken(newToken), #json, "", "", "", false, "", "", request,
);
};
// The response map is keyed by the RESOLVED calendar id (the user's email),
// NOT "primary". Iterate EVERY returned calendar and union its busy periods.
var busy : [(Text, Text)] = [];
switch (response.calendars) {
case (?calendars) {
for ((_id, cal) in calendars.entries()) {
switch (cal.busy) {
case (?periods) {
for (p in periods.vals()) {
switch (p.start, p.end) {
case (?s, ?e) busy := Array.concat(busy, [(s, e)]);
case _ {};
};
};
};
case null {};
};
};
};
case null {};
};
busy;
};
// --- Availability math (re-exported from google-oauth's tested DateTime) ---
//
// Do NOT re-implement RFC 3339 parsing — a digit parse that forgets to subtract
// '0' (48) reads "2026" as 55354, so busy intervals land in the wrong year and
// availability breaks silently (compiles, never traps). These thin re-exports
// let callers use `LibCalendar.isSlotFree` / `.rfc3339ToNanos` with no extra
// import; the implementation lives in `mo:google-oauth/DateTime`.
public func rfc3339ToNanos(s : Text) : Int = DateTime.rfc3339ToNanos(s);
public func nanosToRfc3339(ns : Int) : Text = DateTime.nanosToRfc3339(ns);
public func overlaps(aStart : Int, aEnd : Int, bStart : Int, bEnd : Int) : Bool =
DateTime.overlaps(aStart, aEnd, bStart, bEnd);
public func isSlotFree(slotStart : Int, slotEnd : Int, busy : [(Text, Text)]) : Bool =
DateTime.isSlotFree(slotStart, slotEnd, busy);
public func createEvent(
clientId : Text, clientSecret : Text, connection : CalendarConnection,
caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
summary : Text, startDateTime : Text, endDateTime : Text,
) : async* Text {
let start : EventDateTime = { EventDateTime.init {} with dateTime = ?startDateTime };
let end : EventDateTime = { EventDateTime.init {} with dateTime = ?endDateTime };
let event : Event = { Event.init {} with
summary = ?summary;
start = ?start;
end = ?end;
};
let created : Event = try {
await* calendar_events_insert(
configForToken(connection.accessToken), "primary", #json,
"", "", "", false, "", "",
0, 10, true, #all, false, event,
);
} catch e {
let ?newToken = await* refreshIfNeeded(
clientId, clientSecret, connection, caller, calendarConnections, e.message(),
) else Runtime.trap("Calendar API failed");
await* calendar_events_insert(
configForToken(newToken), "primary", #json,
"", "", "", false, "", "",
0, 10, true, #all, false, event,
);
};
switch (created.id) {
case (?id) id;
case null "";
};
};
func eventSummariesOf(events : Events) : EventSummaryList {
let items = switch (events.items) {
case (?items) items;
case null [];
};
Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
id = switch (e.id) { case (?id) id; case null "" };
summary = switch (e.summary) { case (?s) s; case null "(no title)" };
start = switch (e.start) {
case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
case null "";
};
end = switch (e.end) {
case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
case null "";
};
// All-day events carry `date` but no `dateTime`.
isAllDay = switch (e.start) {
case (?dt) switch (dt.dateTime) { case (?_) false; case null true };
case null false;
};
transparency = switch (e.transparency) { case (?t) t; case null "" };
eventType = switch (e.eventType) { case (?t) t; case null "" };
});
};
};
4b. Availability / busy times — use FreeBusy, NOT events.list
Any "when is this person free / busy", booking, or Calendly-style feature MUST
read availability through FreeBusy (calendar_freebusy_query), not
calendar_events_list. FreeBusy is purpose-built for this: a single POST returns
the merged busy intervals across the user's calendars, with recurring events
already expanded server-side — you never page through events, expand recurrences,
or union overlapping blocks yourself. It also folds in out-of-office and all-day
blocks. Reserve calendar_events_list for showing the app's own event list and
_insert / _delete for event CRUD.
The busyTimes helper in the lib/calendar.mo block above is the reference
implementation: it builds a FreeBusyRequest for items = [{ id = "primary" }]
over [timeMin, timeMax], does the single-refresh-on-401 retry, and — crucially
— iterates every calendar the response returns (the map is keyed by the
resolved calendar id, not "primary") and unions their busy periods.
Before comparing each (start, end) against your candidate slots, parse it
to an absolute instant honoring the trailing offset — Google returns timed
periods with a Z or a numeric offset (2026-07-21T14:00:00+02:00), and
all-day blocks as a bare YYYY-MM-DD date. Truncating at the seconds and
ignoring the offset shifts every busy interval by the offset (e.g. 2h in
Zurich summer), so busy blocks miss the slots they should hide. Use
LibCalendar.rfc3339ToNanos (a tested re-export of mo:google-oauth/DateTime)
— it honors the offset and handles all-day dates — then overlap numerically. Do
not hand-roll a parser that stops at the seconds.
End to end, the whole availability flow lives in nanosecond instants and only
touches text at the edges: anchor the window with LibCalendar.rfc3339ToNanos,
build the candidate grid with plain integer arithmetic, filter with
LibCalendar.isSlotFree, then format the survivors back with
LibCalendar.nanosToRfc3339 so they are ready to display and to pass straight to
createEvent (whose startDateTime / endDateTime are RFC 3339 text). The grid
below is a fixed UTC window; real working-hours / timezone policy is app-specific,
but the parse → integer-math → format shape is the same:
func availableSlots(
clientId : Text, clientSecret : Text, connection : LibCalendar.CalendarConnection,
caller : Principal, calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
windowStart : Text, // e.g. "2026-07-21T09:00:00Z"
slotCount : Nat, // number of consecutive slots to consider
slotMinutes : Nat, // slot length, e.g. 30
) : async* [(Text, Text)] {
let slotNs = slotMinutes * 60 * 1_000_000_000;
let start0 = LibCalendar.rfc3339ToNanos(windowStart);
// Candidate grid of [s, s+slot) instants.
let candidates = Array.tabulate<(Int, Int)>(slotCount, func(i) {
let s = start0 + i * slotNs;
(s, s + slotNs);
});
let windowEnd = start0 + slotCount * slotNs;
let busy = await* LibCalendar.busyTimes(
clientId, clientSecret, connection, caller, calendarConnections,
windowStart, LibCalendar.nanosToRfc3339(windowEnd),
);
let free = Array.filter<(Int, Int)>(candidates, func(s) = LibCalendar.isSlotFree(s.0, s.1, busy));
Array.map<(Int, Int), (Text, Text)>(
free, func(s) = (LibCalendar.nanosToRfc3339(s.0), LibCalendar.nanosToRfc3339(s.1)),
);
};
5. Available API surface
google-oauth (OAuth 2.0 mechanics)
| Function |
Purpose |
OAuth.urlEncode(text) |
RFC 3986 percent-encoding for form bodies |
OAuth.parseTokenResponse(text) |
Parse Google token-endpoint JSON |
OAuth.exchangeAuthorizationCode(...) |
Exchange auth code for tokens |
OAuth.refreshAccessToken(...) |
Refresh an expired access token |
OAuth.generateCodeVerifier() |
Generate PKCE code_verifier (on-chain randomness) |
OAuth.computeCodeChallenge(verifier) |
Compute PKCE code_challenge (S256) |
OAuth.buildAuthorizeUrl(...) |
Build the Google OAuth authorize URL |
OAuth.getUserEmail(accessToken) |
Fetch the connected email via OIDC userinfo (needs only openid email) |
Availability math (LibCalendar re-exports of mo:google-oauth/DateTime)
lib/calendar.mo re-exports these tested helpers, so call them as LibCalendar.*
with no extra import. Times are absolute nanoseconds since the Unix epoch,
matching Time.now().
| Function |
Purpose |
LibCalendar.rfc3339ToNanos(text) |
Offset-aware RFC 3339 -> nanoseconds (honors Z / ±HH:MM, bare dates) |
LibCalendar.nanosToRfc3339(ns) |
Nanoseconds -> UTC RFC 3339 text (…Z), ready for createEvent |
LibCalendar.overlaps(aStart, aEnd, bStart, bEnd) |
Half-open interval overlap test |
LibCalendar.isSlotFree(slotStart, slotEnd, busy) |
Slot is free of every (start, end) RFC 3339 busy pair |
googlecalendar-client (Calendar REST API v3)
The canonical actor above intentionally implements only upcoming-event listing
and event creation; for availability/busy times use the FreeBusy helper in §4b.
For another generated operation, keep bearer authentication and
is_replicated = ?false, then apply the same single-refresh-retry pattern as
refreshIfNeeded.
The generated package also exposes:
| Function |
Module |
Purpose |
calendar_events_list |
EventsApi |
List events on a calendar |
calendar_events_get |
EventsApi |
Get an event by id |
calendar_events_insert |
EventsApi |
Create an event |
calendar_events_update |
EventsApi |
Update an event (PUT) |
calendar_events_patch |
EventsApi |
Patch an event (PATCH) |
calendar_events_delete |
EventsApi |
Delete an event |
calendar_events_move |
EventsApi |
Move an event to another calendar |
calendar_events_quickAdd |
EventsApi |
Create event from text ("Lunch at noon") |
calendar_events_instances |
EventsApi |
List instances of a recurring event |
calendar_freebusy_query |
FreebusyApi |
Check free/busy across calendars |
calendar_calendarList_list |
CalendarListApi |
List user's calendars |
calendar_calendarList_get |
CalendarListApi |
Get a calendar list entry |
calendar_calendars_get |
CalendarsApi |
Get calendar metadata |
calendar_calendars_insert |
CalendarsApi |
Create a secondary calendar |
6. Cycles and response sizes
The google-oauth library uses Call.httpRequest from mo:ic/Call, which
auto-computes and attaches the exact required cycles via the
ic0.cost_http_request system API. No manual cycle budgeting is needed
for token exchange or refresh calls.
For googlecalendar-client calls, defaultConfig.cycles = 30_000_000_000
(30B). A typical list/insert costs ~10–15B cycles. Set
max_response_bytes = ?2_000_000 for event list reads that may include
large payloads.
7. Things that will bite you
is_replicated = ?false — see §3. Non-negotiable.
- Google refresh tokens do NOT rotate. Unlike X/Twitter, Google does
not issue a new
refresh_token on each refresh. Keep the original
refresh_token and only persist the new access_token.
- Access tokens expire in 1 hour. The
refreshIfNeeded helper catches
HTTP 401, silently refreshes via google-oauth.refreshAccessToken, and
retries once. If the refresh also fails, surface "re-connect your account".
- Callback URI exact-match. Every character (trailing slash, query
string, port) must match between the authorize URL and the redirect.
Google returns
redirect_uri_mismatch otherwise. Use the fixed
window.location.origin + "/connect/calendar" for redirectUri — the same
value the settings page displays and the /connect/calendar route owns — and
register that exact URI on the Google Web client. Do not build it from
window.location.pathname, which varies by page.
- Pass the displayed value to
startCalendarOAuth unchanged — never the raw
*.icp0.io canister URL. A Caffeine app is served at several origins (the
*-draft.caffeine.xyz draft, the *.caffeine.xyz live domain, and the raw
<canister-id>.icp0.io URL). Compute the redirect URI in one shared
helper (window.location.origin + "/connect/calendar") and use that same
helper both for the copyable field on the settings page and for the value
handed to startCalendarOAuth. If the value sent to Google (via
startCalendarOAuth) differs from what the settings page showed and the admin
registered — e.g. a build-time/config value or the *.icp0.io canister origin
— Google returns redirect_uri_mismatch.
- RFC 3339 timestamps. Calendar uses RFC 3339 strings
(
2026-07-10T15:00:00-07:00). For all-day events set
EventDateTime.date (YYYY-MM-DD) instead of dateTime.
createEvent times need a zone. The dateTime you pass to createEvent
MUST carry a UTC offset (…Z or …+02:00) or you MUST also set
EventDateTime.timeZone (an IANA name like "Europe/Zurich"). A bare
2026-07-10T15:00:00 with neither is rejected by Google. Prefer sending an
offset-qualified string so the event lands at the intended wall-clock time.
calendarId = "primary" refers to the authenticated user's default
calendar. Named/shared calendars use their calendar-ID (an email-like
address).
- Availability = FreeBusy, not
events.list. For "am I free / busy" use
calendar_freebusy_query (§4b): one POST returns merged busy intervals with
recurrences expanded server-side. Rebuilding availability from events.list
means paging, expanding recurring events, and merging overlaps by hand — easy
to get wrong, and the classic cause of "the booking link shows me free when I'm
busy".
maxAttendees and maxResults must be ≥ 1. Google rejects maxAttendees=0
/ maxResults=0 with HTTP 400 (documented minimum is 1). The listUpcomingEvents
and createEvent recipes pass maxAttendees = 10; never pass 0 for these on
any events endpoint.
- FreeBusy responses are keyed by the resolved calendar ID, not the string
you queried. When you call
calendar_freebusy_query for "primary", Google
resolves it and returns the calendars map keyed by the real calendar ID (the
user's email address), not the literal "primary". Do not look up
"primary" in the response — that finds nothing and makes every slot look
free (a common availability bug). Instead, iterate over every calendar the
response returns and union all their busy intervals, then subtract those
from your candidate slots. Parse each interval's start/end as RFC 3339
allowing a trailing Z or a numeric offset (+02:00); compare instants, not
raw strings.
- Parse RFC 3339 with
LibCalendar.rfc3339ToNanos (re-exported from
mo:google-oauth/DateTime) — do NOT re-implement it. A hand-rolled parser
that forgets to subtract '0' (48) per digit reads "2026" as 55354, so
every busy interval lands in the wrong year, overlap checks never match, and
availability is silently wrong — the code still compiles and never traps, so the
bug is invisible until a user is double-booked. Use the tested helper.
- HTTP 429 rate-limit. Surface the error to the caller; never
silently retry a write inside the canister — a retry may create a
duplicate event.
- Don't expose the access token.
calendarConnections is read only by
Map.get(calendarConnections, ..., caller) inside API calls. No
getMyCalendarConnection, no getMyAccessToken, no iterator. A leaked
bearer is a per-user account compromise.
alt = #json for all Calendar API v3 calls. Leave optional string
parameters "" and prettyPrint = false.
- API query parameters are plain positional values, not
?T — never pass
null for one. The client's function parameters are Text / Bool / enum /
Nat (e.g. alt, fields, prettyPrint); pass real values like #json,
"", false, 10 — null will not type-check. (Respect each param's
documented minimum: maxAttendees / maxResults must be ≥ 1, see below.) Only
model values (Event, EventDateTime, FreeBusyRequest) are optional
?T.
- Combined Gmail + Calendar apps: request the scope union, and learn the
address via
OAuth.getUserEmail. The union of openid email + .../calendar
.../gmail.send covers availability, sending, and the connected address (via
OIDC userinfo) — no gmail.readonly needed unless the app actually reads mail.
Never drop a scope when merging recipes — see "Combined Gmail + Calendar apps".
- Build
Event / EventDateTime with init {} then record-update
the fields you need — all fields are optional (?T); leave the rest null.
- PATCH/PUT/DELETE are forced non-replicated in the generated client
(the
googlecalendar-client sets is_replicated = ?false on these
methods automatically). For GET/POST, set it explicitly in your Config.
Frontend
Every build using this skill MUST ship all four items below. (If the app
also uses the Gmail connector, follow "Combined Gmail + Calendar apps" below
instead — it replaces /settings/calendar + /connect/calendar with one shared
/settings/google + /connect/google. The requirements below still apply; only
the two paths change.) These are acceptance criteria, not suggestions —
verify each before the build is done. These three are the requirements builds
skip, and any one missing makes the connector broken, not merely
incomplete:
- The credentials page exists and is reachable. The app MUST have the
/settings/calendar page with Client ID/Secret inputs (item 2), and a signed-in
admin MUST be able to reach it — via a nav link or the not-configured prompt on
the connect page. A "Connect Google Calendar" button with no page to enter
credentials is the most common failure and leaves the connector unusable.
- The admin settings page displays the literal, copyable redirect URI. Not a
<your-domain> placeholder, not "your app URL + /connect/calendar" as text for
the admin to assemble — the actual string
window.location.origin + "/connect/calendar" rendered in a read-only field
the admin can copy. Concretely: an app served from https://my-app.caffeine.xyz
must show a field containing exactly
https://my-app.caffeine.xyz/connect/calendar and nothing else. Without it the
admin cannot register the URI in Google and every connection fails.
/connect/calendar is a real route that handles Google's callback — not a
button-only page. If it falls through to a catch-all/home redirect, or calls
completeCalendarOAuth before the authenticated actor is ready, the connection
silently fails and the app shows "not connected".
A login flow — required. Calendar cannot work without a non-anonymous
caller; the per-user OAuth handshake stores tokens keyed by
caller : Principal, and the admin credential setter gates on
#admin. The login flow comes from
extension-authorization:
useInternetIdentity, login/logout buttons, the useActor plumbing
that injects the authenticated identity into every backend call.
An admin settings page — /settings/calendar (admin-gated). This
page is required; a Calendar build is incomplete without it:
- Show a "How to get your Google credentials" panel before the credential
inputs. Reassure the admin it is a one-time, ~5-minute setup, and walk
through these numbered steps (the agent's completion message must repeat
the same steps):
- open the Google Cloud Console and
sign in with any Google account;
- create or pick a project;
- enable the Google Calendar API (APIs & Services → Library → search
"Google Calendar API" → Enable);
- configure the OAuth consent screen (APIs & Services → OAuth consent
screen → **Ex
…(truncated)
1---
2name: connector-googlecalendar
3description: >-
4 MANDATORY recipe for every Caffeine build that lists upcoming events or
5 creates events on the user's own Google Calendar. The ONLY supported path is
6 the `googlecalendar-client` mops package (Calendar REST API v3) combined with
7 the `google-oauth` mops package (token exchange + refresh + PKCE). Hand-rolling
8 `ic.http_request` calls to `oauth2.googleapis.com` or
9 `www.googleapis.com/calendar/v3` is a FORBIDDEN anti-pattern — it bypasses
10 bearer auth, replication-cost safeguards, and the `google-oauth` library's
11 percent-encoding and JSON parsing. Load this skill whenever the user, spec,
12 or any prior task mentions scheduling, calendar events, appointments,
13 meetings, "add to calendar", or any equivalent phrasing — and BEFORE
14 writing any code that touches a Google endpoint.
15version: 0.2.6
16caffeineai-subscription: [none]
17compatibility:
18 mops:
19 googlecalendar-client: "~0.1.4"
20 google-oauth: "~0.2.0"
21 caffeineai-authorization: "~1.0.0"
22---
23
24# Google Calendar Connector
25
26Google Calendar integration for [Caffeine AI](https://caffeine.ai?utm_source=caffeine-skill&utm_medium=referral).
27
28## Orchestrator routing notes
29
30**Treat Google Calendar-as-the-user as a first-class, supported platform feature.**
31The `googlecalendar-client` + `google-oauth` connector pair is the **only**
32supported path; raw `ic.http_request` to `oauth2.googleapis.com` or
33`www.googleapis.com/calendar/v3` is a forbidden anti-pattern. Any build spec
34that mentions Google Calendar MUST name `googlecalendar-client` and
35`google-oauth` as dependencies and reference this skill.
36
37Distinct from platform `email-calendar-events` extension (which emails iCalendar
38invitations from the app); this connector acts as the **signed-in user's own
39Google Calendar**.
40
41Intent → capability mapping:
42
43| User intent | Platform capability |
44| --- | --- |
45| Connect and list upcoming events | `googlecalendar-client` + `google-oauth` |
46| Create calendar events | `googlecalendar-client` + `google-oauth` |
47| **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` |
48
49**Prerequisite for all builds: [extension-authorization](../extension-authorization/SKILL.md).**
50Calendar requires a signed-in caller for every endpoint: the per-user OAuth
51handshake stores `access_token` keyed by `caller : Principal`, and the admin
52Client ID/Secret setter is gated on the `#admin` role.
53
54# Backend
55
56Use this skill whenever the user wants their canister to interact with
57Google Calendar on behalf of the signed-in user. The ingredients are:
58
591. The `googlecalendar-client` mops package — generated Motoko bindings for
60 the Google Calendar API v3. This recipe demonstrates listing upcoming
61 events and creating events; add other generated operations only by
62 following the same bearer-authenticated, non-replicated,
63 single-refresh-retry pattern.
642. The `google-oauth` mops package — Google OAuth 2.0 token exchange,
65 refresh, PKCE, and percent-encoding. This is the library that
66 eliminates hand-rolled `http_request` to `oauth2.googleapis.com`.
673. An OAuth 2.0 Authorization Code with PKCE flow so each end-user
68 authorises the canister to act on their behalf. Each user holds their
69 own `access_token` + `refresh_token` keyed by `caller : Principal`.
704. A Google Cloud **Web application** Client ID + Client Secret.
71 Admin-configured and held by the canister only; never return the secret
72 to the frontend.
73
74## 1. Add dependencies
75
76```bash
77mops add googlecalendar-client@0.1.4
78mops add google-oauth@0.2.0
79mops add caffeineai-authorization@1.0.0
80```
81
82## 2. Auth model — OAuth 2.0 PKCE per user, on-chain exchange + refresh
83
84Identical to the Gmail connector. Every end-user authorises the canister
85independently via the Authorization Code with PKCE flow. The canister:
86
871. Generates a PKCE `code_verifier` and `code_challenge` (via `google-oauth`).
882. Builds the Google authorize URL (via `google-oauth.buildAuthorizeUrl`).
893. The frontend redirects the user to Google; after consent, Google
90 redirects back with a `code` parameter.
914. The canister exchanges the code for tokens (via
92 `google-oauth.exchangeAuthorizationCode`) — **on-chain**, non-replicated.
935. The canister stores `access_token` + `refresh_token` keyed by `caller`.
946. When the 1-hour access token expires (HTTP 401), the canister silently
95 refreshes it (via `google-oauth.refreshAccessToken`) and retries.
96
97### Google Cloud Console setup
98
991. Create a Google OAuth 2.0 **Web application** client.
1002. The app's Calendar settings page must display this literal callback URI in
101 a copyable field: `window.location.origin + "/connect/calendar"` — for
102 example, `https://my-app.caffeine.xyz/connect/calendar`. The app
103 administrator must manually copy that displayed value into Google Cloud
104 Console under **Authorized redirect URIs**. Register every deployed origin
105 where users can connect Calendar (for example, the draft and live app
106 origins) as separate authorized redirect URIs.
1073. Enable only the Calendar scopes the app needs on the consent screen.
1084. Enter the Client ID and Client Secret through the app's admin settings
109 page. The canister uses the secret for the token exchange; the frontend
110 must never receive it.
111
112PKCE binds each authorization code to the canister-generated verifier, while
113the Web client registration binds the browser callback to the deployed app.
114The callback URI passed to `startCalendarOAuth` must be the exact same value
115the settings page displays and the administrator registered.
116
117### OAuth scopes
118
119| Scope | Purpose |
120| --- | --- |
121| `https://www.googleapis.com/auth/calendar` | Full read/write access to calendars |
122| `https://www.googleapis.com/auth/calendar.events` | Read/write access to events only |
123| `https://www.googleapis.com/auth/calendar.readonly` | Read-only access to calendars |
124| `https://www.googleapis.com/auth/calendar.events.readonly` | Read-only access to events |
125
126Request `calendar` (full read/write) for a typical CRUD app; use
127`.readonly` variants for read-only views.
128
129### Storing tokens
130
131The bearer **never leaves the canister**. The frontend only ever learns
132whether the caller has connected (a `Bool`), never the tokens themselves.
133
134- A `Map<Principal, CalendarConnection>` keyed by caller. Expose exactly the
135 endpoints listed in §4 — `isMyCalendarConnected`, `startCalendarOAuth`,
136 `completeCalendarOAuth`, `listUpcomingEvents`, `createEvent`,
137 `disconnectMyCalendar` — every endpoint gated on `not caller.isAnonymous()`.
138 **Do not add any endpoint that returns `access_token` / `refresh_token` /
139 the full `CalendarConnection`.**
140- Store one pending OAuth flow per caller: the PKCE `code_verifier`, exact
141 `redirectUri`, and a random `state` nonce. Consume it when the callback is
142 completed; do not accept a replacement redirect URI from the frontend.
143
144### Google refresh tokens do NOT rotate
145
146Unlike X/Twitter, Google does **not** rotate the `refresh_token` on each
147refresh. The same `refresh_token` can be reused until the user revokes
148access or the authorization is re-issued. This simplifies the refresh
149logic: just persist the new `access_token`, keep the old `refresh_token`.
150
151## 3. `is_replicated = ?false` is REQUIRED
152
1531. **Security.** A replicated HTTP outcall sends the request from every
154 node in the subnet. Each carries the `Authorization: Bearer <token>`
155 header — a leaked bearer from any node compromises the user's Google
156 account.
1572. **Billing.** Replicated outcalls produce N parallel API calls. The IC
158 charges ~13× the cycles, and Google counts each toward quota.
1593. **Determinism.** Calendar write responses are non-deterministic (unique
160 event `id`/`etag`, per-request timestamps). Replicated consensus would
161 fail; non-replicated bypasses consensus entirely.
162
163→ Always: `is_replicated = ?false` on every `Config`.
164
165## 4. Canonical layout
166
167The default shape: **admin Client ID/Secret + per-user OAuth**. The
168canister owner registers one Google Cloud Desktop app and pastes its
169Client ID + Secret into canister-level config; every end-user runs the
170OAuth 2.0 PKCE handshake against that one credential and ends up with
171their own `access_token` + `refresh_token`.
172
173The example spans four files:
174
175- `src/backend/main.mo` — the actor: state + `include`s only.
176- `src/backend/mixins/calendar-config.mo` — admin-gated Client ID + Secret.
177- `src/backend/mixins/calendar-messaging.mo` — per-user OAuth + event ops.
178- `src/backend/lib/calendar.mo` — `googlecalendar-client` + `google-oauth` glue.
179
180```motoko filepath=src/backend/main.mo
181import Map "mo:core/Map";
182import Principal "mo:core/Principal";
183import AccessControl "mo:caffeineai-authorization/access-control";
184import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
185import MixinCalendarConfig "mixins/calendar-config";
186import MixinCalendarMessaging "mixins/calendar-messaging";
187import LibCalendar "lib/calendar";
188
189actor {
190 let accessControlState = AccessControl.initState();
191 include MixinAuthorization(accessControlState, null);
192
193 let calendarConfig = {
194 var clientId : Text = "";
195 var clientSecret : Text = "";
196 };
197 include MixinCalendarConfig(accessControlState, calendarConfig);
198
199 let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection> = Map.empty();
200 let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth> = Map.empty();
201 include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
202};
203```
204
205```motoko filepath=src/backend/mixins/calendar-config.mo
206import AccessControl "mo:caffeineai-authorization/access-control";
207import Runtime "mo:core/Runtime";
208
209mixin (
210 accessControlState : AccessControl.AccessControlState,
211 calendarConfig : { var clientId : Text; var clientSecret : Text },
212) {
213 public query func isCalendarConfigured() : async Bool {
214 calendarConfig.clientId.size() > 0;
215 };
216
217 public shared ({ caller }) func setCalendarCredentials(clientId : Text, clientSecret : Text) : async () {
218 if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
219 Runtime.trap("Unauthorized: Only admins can set Calendar credentials");
220 };
221 calendarConfig.clientId := clientId;
222 calendarConfig.clientSecret := clientSecret;
223 };
224};
225```
226
227```motoko filepath=src/backend/mixins/calendar-messaging.mo
228import Map "mo:core/Map";
229import Principal "mo:core/Principal";
230import Runtime "mo:core/Runtime";
231import LibCalendar "../lib/calendar";
232
233mixin (
234 calendarConfig : { var clientId : Text; var clientSecret : Text },
235 calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
236 pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth>,
237) {
238 public query ({ caller }) func isMyCalendarConnected() : async Bool {
239 Map.containsKey(calendarConnections, Principal.compare, caller);
240 };
241
242 public shared ({ caller }) func startCalendarOAuth(redirectUri : Text) : async Text {
243 if (caller.isAnonymous()) {
244 Runtime.trap("Sign in to connect Google Calendar");
245 };
246 if (calendarConfig.clientId.size() == 0) {
247 Runtime.trap("Calendar is not configured (admin must set credentials)");
248 };
249 await* LibCalendar.startAuthorize(
250 calendarConfig.clientId, redirectUri, caller, pendingCalendarFlows,
251 );
252 };
253
254 public shared ({ caller }) func completeCalendarOAuth(code : Text, state : Text) : async () {
255 if (caller.isAnonymous()) {
256 Runtime.trap("Sign in to connect Google Calendar");
257 };
258 if (calendarConfig.clientId.size() == 0) {
259 Runtime.trap("Calendar is not configured");
260 };
261 let ?pending = Map.get(pendingCalendarFlows, Principal.compare, caller) else {
262 Runtime.trap("No pending OAuth flow — call startCalendarOAuth first");
263 };
264 if (state != pending.state) {
265 Runtime.trap("OAuth state did not match the pending Calendar flow");
266 };
267 Map.remove(pendingCalendarFlows, Principal.compare, caller);
268 let connection = await* LibCalendar.exchangeCode(
269 calendarConfig.clientId, calendarConfig.clientSecret, code,
270 pending.redirectUri, pending.codeVerifier,
271 );
272 Map.add(calendarConnections, Principal.compare, caller, connection);
273 };
274
275 public shared ({ caller }) func listUpcomingEvents(
276 timeMin : Text, timeMax : Text, maxResults : Nat,
277 ) : async LibCalendar.EventSummaryList {
278 if (caller.isAnonymous()) {
279 Runtime.trap("Sign in to list events");
280 };
281 let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
282 Runtime.trap("Connect your Google Calendar first");
283 };
284 await* LibCalendar.listUpcomingEvents(
285 calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
286 calendarConnections, timeMin, timeMax, maxResults,
287 );
288 };
289
290 public shared ({ caller }) func createEvent(
291 summary : Text, startDateTime : Text, endDateTime : Text,
292 ) : async Text {
293 if (caller.isAnonymous()) {
294 Runtime.trap("Sign in to create events");
295 };
296 let ?connection = Map.get(calendarConnections, Principal.compare, caller) else {
297 Runtime.trap("Connect your Google Calendar first");
298 };
299 await* LibCalendar.createEvent(
300 calendarConfig.clientId, calendarConfig.clientSecret, connection, caller,
301 calendarConnections, summary, startDateTime, endDateTime,
302 );
303 };
304
305 public shared ({ caller }) func disconnectMyCalendar() : async () {
306 if (caller.isAnonymous()) {
307 Runtime.trap("Sign in to disconnect");
308 };
309 Map.remove(calendarConnections, Principal.compare, caller);
310 };
311};
312```
313
314```motoko filepath=src/backend/lib/calendar.mo
315import Array "mo:core/Array";
316import Map "mo:core/Map";
317import Nat64 "mo:core/Nat64";
318import Principal "mo:core/Principal";
319import Runtime "mo:core/Runtime";
320import Text "mo:core/Text";
321import OAuth "mo:google-oauth/OAuth";
322import DateTime "mo:google-oauth/DateTime";
323import { calendar_events_list; calendar_events_insert } "mo:googlecalendar-client/Apis/EventsApi";
324import { calendar_freebusy_query } "mo:googlecalendar-client/Apis/FreebusyApi";
325import { type Event; JSON = Event } "mo:googlecalendar-client/Models/Event";
326import { type EventDateTime; JSON = EventDateTime } "mo:googlecalendar-client/Models/EventDateTime";
327import { type Events; JSON = Events } "mo:googlecalendar-client/Models/Events";
328import { type FreeBusyRequest; JSON = FreeBusyRequest } "mo:googlecalendar-client/Models/FreeBusyRequest";
329import { type FreeBusyRequestItem; JSON = FreeBusyRequestItem } "mo:googlecalendar-client/Models/FreeBusyRequestItem";
330import { type FreeBusyResponse } "mo:googlecalendar-client/Models/FreeBusyResponse";
331import { defaultConfig; type Config } "mo:googlecalendar-client/Config";
332
333module {
334 public type CalendarConnection = {
335 accessToken : Text;
336 refreshToken : Text;
337 };
338
339 public type PendingOAuth = {
340 codeVerifier : Text;
341 redirectUri : Text;
342 state : Text;
343 };
344
345 public type EventSummary = {
346 id : Text;
347 summary : Text;
348 start : Text;
349 end : Text;
350 // Signals that let the frontend tell a real meeting from a marker:
351 // isAllDay = the event has a date but no time (all-day block).
352 // transparency = "transparent" (shows as free) or "opaque"/"" (busy).
353 // eventType = "default" | "outOfOffice" | "focusTime" | "workingLocation".
354 // To count/show only real meetings, keep timed, opaque, default events.
355 isAllDay : Bool;
356 transparency : Text;
357 eventType : Text;
358 };
359
360 public type EventSummaryList = [EventSummary];
361
362 let SCOPES : Text = "https://www.googleapis.com/auth/calendar";
363
364 func configForToken(token : Text) : Config {
365 {
366 defaultConfig with
367 auth = ?#bearer(token);
368 is_replicated = ?false;
369 max_response_bytes = ?Nat64.fromNat(2_000_000);
370 };
371 };
372
373 func refreshIfNeeded(
374 clientId : Text, clientSecret : Text, connection : CalendarConnection,
375 caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
376 errorMsg : Text,
377 ) : async* ?Text {
378 if (not (errorMsg.contains(#text("401")) or errorMsg.contains(#text("Unauthorized")))) {
379 Runtime.trap("Calendar API failed: " # errorMsg);
380 };
381 let refreshed = await OAuth.refreshAccessToken(clientId, clientSecret, connection.refreshToken);
382 let newToken = accessTokenOf(refreshed, "Token refresh");
383 Map.add(calendarConnections, Principal.compare, caller, {
384 connection with accessToken = newToken;
385 });
386 ?newToken;
387 };
388
389 public func startAuthorize(
390 clientId : Text, redirectUri : Text, caller : Principal,
391 pendingFlows : Map.Map<Principal, PendingOAuth>,
392 ) : async* Text {
393 let codeVerifier = await OAuth.generateCodeVerifier();
394 let state = await OAuth.generateCodeVerifier();
395 Map.add(pendingFlows, Principal.compare, caller, {
396 codeVerifier;
397 redirectUri;
398 state;
399 });
400 OAuth.buildAuthorizeUrl(clientId, redirectUri, SCOPES, state, OAuth.computeCodeChallenge(codeVerifier));
401 };
402
403 public func exchangeCode(
404 clientId : Text, clientSecret : Text, code : Text,
405 redirectUri : Text, codeVerifier : Text,
406 ) : async* CalendarConnection {
407 let tokens = await OAuth.exchangeAuthorizationCode(clientId, clientSecret, code, redirectUri, codeVerifier);
408 let accessToken = accessTokenOf(tokens, "Token exchange");
409 let refreshToken = switch (tokens.refreshToken) {
410 case (?t) t;
411 case null Runtime.trap("Token exchange failed: missing refresh_token");
412 };
413 { accessToken; refreshToken };
414 };
415
416 func accessTokenOf(tokens : OAuth.TokenResponse, operation : Text) : Text {
417 switch (tokens.error) {
418 case (?error) {
419 let description = switch (tokens.errorDescription) {
420 case (?value) ": " # value;
421 case null "";
422 };
423 Runtime.trap(operation # " failed: " # error # description);
424 };
425 case null {};
426 };
427 switch (tokens.accessToken) {
428 case (?token) token;
429 case null Runtime.trap(operation # " failed: missing access_token");
430 };
431 };
432
433 // Lists events in [timeMin, timeMax). Pass timeMax = "" for an open-ended
434 // "everything from now" list; pass both to bound a single day/week (e.g.
435 // timeMin = start-of-tomorrow, timeMax = start-of-day-after) so the count is
436 // exact. Both are RFC 3339 strings; include an offset ("…Z" or "…+02:00").
437 public func listUpcomingEvents(
438 clientId : Text, clientSecret : Text, connection : CalendarConnection,
439 caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
440 timeMin : Text, timeMax : Text, maxResults : Nat,
441 ) : async* EventSummaryList {
442 if (timeMin.size() == 0) {
443 Runtime.trap("timeMin must be an RFC 3339 timestamp");
444 };
445 let events : Events = try {
446 await* calendar_events_list(
447 configForToken(connection.accessToken), "primary", #json,
448 "", "", "", false, "", "",
449 false, [], "", 10, maxResults, #starttime,
450 "", [], "", [], false, false, true, "",
451 timeMax, timeMin, "", "",
452 );
453 } catch e {
454 let ?newToken = await* refreshIfNeeded(
455 clientId, clientSecret, connection, caller, calendarConnections, e.message(),
456 ) else Runtime.trap("Calendar API failed");
457 await* calendar_events_list(
458 configForToken(newToken), "primary", #json,
459 "", "", "", false, "", "",
460 false, [], "", 10, maxResults, #starttime,
461 "", [], "", [], false, false, true, "",
462 timeMax, timeMin, "", "",
463 );
464 };
465 eventSummariesOf(events);
466 };
467
468 // Availability uses FreeBusy (POST + JSON body), NOT events.list: one call
469 // returns the merged busy intervals across the user's calendars with recurring
470 // events already expanded server-side — no paging, recurrence expansion, or
471 // client-side merging. Returns the owner's busy intervals in [timeMin, timeMax]
472 // as raw RFC 3339 (start, end) pairs; timeMin/timeMax are UTC "…Z" strings.
473 // Single-refresh-on-401 retry.
474 public func busyTimes(
475 clientId : Text, clientSecret : Text, connection : CalendarConnection,
476 caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
477 timeMin : Text, timeMax : Text,
478 ) : async* [(Text, Text)] {
479 let request : FreeBusyRequest = {
480 FreeBusyRequest.init {} with
481 timeMin = ?timeMin;
482 timeMax = ?timeMax;
483 items = ?[{ FreeBusyRequestItem.init {} with id = ?"primary" }];
484 };
485 let response : FreeBusyResponse = try {
486 await* calendar_freebusy_query(
487 configForToken(connection.accessToken), #json, "", "", "", false, "", "", request,
488 );
489 } catch e {
490 let ?newToken = await* refreshIfNeeded(
491 clientId, clientSecret, connection, caller, calendarConnections, e.message(),
492 ) else Runtime.trap("Calendar API failed");
493 await* calendar_freebusy_query(
494 configForToken(newToken), #json, "", "", "", false, "", "", request,
495 );
496 };
497 // The response map is keyed by the RESOLVED calendar id (the user's email),
498 // NOT "primary". Iterate EVERY returned calendar and union its busy periods.
499 var busy : [(Text, Text)] = [];
500 switch (response.calendars) {
501 case (?calendars) {
502 for ((_id, cal) in calendars.entries()) {
503 switch (cal.busy) {
504 case (?periods) {
505 for (p in periods.vals()) {
506 switch (p.start, p.end) {
507 case (?s, ?e) busy := Array.concat(busy, [(s, e)]);
508 case _ {};
509 };
510 };
511 };
512 case null {};
513 };
514 };
515 };
516 case null {};
517 };
518 busy;
519 };
520
521 // --- Availability math (re-exported from google-oauth's tested DateTime) ---
522 //
523 // Do NOT re-implement RFC 3339 parsing — a digit parse that forgets to subtract
524 // '0' (48) reads "2026" as 55354, so busy intervals land in the wrong year and
525 // availability breaks silently (compiles, never traps). These thin re-exports
526 // let callers use `LibCalendar.isSlotFree` / `.rfc3339ToNanos` with no extra
527 // import; the implementation lives in `mo:google-oauth/DateTime`.
528 public func rfc3339ToNanos(s : Text) : Int = DateTime.rfc3339ToNanos(s);
529 public func nanosToRfc3339(ns : Int) : Text = DateTime.nanosToRfc3339(ns);
530 public func overlaps(aStart : Int, aEnd : Int, bStart : Int, bEnd : Int) : Bool =
531 DateTime.overlaps(aStart, aEnd, bStart, bEnd);
532 public func isSlotFree(slotStart : Int, slotEnd : Int, busy : [(Text, Text)]) : Bool =
533 DateTime.isSlotFree(slotStart, slotEnd, busy);
534
535 public func createEvent(
536 clientId : Text, clientSecret : Text, connection : CalendarConnection,
537 caller : Principal, calendarConnections : Map.Map<Principal, CalendarConnection>,
538 summary : Text, startDateTime : Text, endDateTime : Text,
539 ) : async* Text {
540 let start : EventDateTime = { EventDateTime.init {} with dateTime = ?startDateTime };
541 let end : EventDateTime = { EventDateTime.init {} with dateTime = ?endDateTime };
542 let event : Event = { Event.init {} with
543 summary = ?summary;
544 start = ?start;
545 end = ?end;
546 };
547 let created : Event = try {
548 await* calendar_events_insert(
549 configForToken(connection.accessToken), "primary", #json,
550 "", "", "", false, "", "",
551 0, 10, true, #all, false, event,
552 );
553 } catch e {
554 let ?newToken = await* refreshIfNeeded(
555 clientId, clientSecret, connection, caller, calendarConnections, e.message(),
556 ) else Runtime.trap("Calendar API failed");
557 await* calendar_events_insert(
558 configForToken(newToken), "primary", #json,
559 "", "", "", false, "", "",
560 0, 10, true, #all, false, event,
561 );
562 };
563 switch (created.id) {
564 case (?id) id;
565 case null "";
566 };
567 };
568
569 func eventSummariesOf(events : Events) : EventSummaryList {
570 let items = switch (events.items) {
571 case (?items) items;
572 case null [];
573 };
574 Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
575 id = switch (e.id) { case (?id) id; case null "" };
576 summary = switch (e.summary) { case (?s) s; case null "(no title)" };
577 start = switch (e.start) {
578 case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
579 case null "";
580 };
581 end = switch (e.end) {
582 case (?dt) switch (dt.dateTime) { case (?t) t; case null switch (dt.date) { case (?d) d; case null "" } };
583 case null "";
584 };
585 // All-day events carry `date` but no `dateTime`.
586 isAllDay = switch (e.start) {
587 case (?dt) switch (dt.dateTime) { case (?_) false; case null true };
588 case null false;
589 };
590 transparency = switch (e.transparency) { case (?t) t; case null "" };
591 eventType = switch (e.eventType) { case (?t) t; case null "" };
592 });
593 };
594};
595```
596
597## 4b. Availability / busy times — use FreeBusy, NOT events.list
598
599Any "when is this person free / busy", booking, or Calendly-style feature MUST
600read availability through **FreeBusy** (`calendar_freebusy_query`), not
601`calendar_events_list`. FreeBusy is purpose-built for this: a single POST returns
602the **merged busy intervals** across the user's calendars, with recurring events
603already expanded server-side — you never page through events, expand recurrences,
604or union overlapping blocks yourself. It also folds in out-of-office and all-day
605blocks. Reserve `calendar_events_list` for showing the app's own event list and
606`_insert` / `_delete` for event CRUD.
607
608The `busyTimes` helper in the `lib/calendar.mo` block above is the reference
609implementation: it builds a `FreeBusyRequest` for `items = [{ id = "primary" }]`
610over `[timeMin, timeMax]`, does the single-refresh-on-401 retry, and — crucially
611— iterates **every** calendar the response returns (the map is keyed by the
612resolved calendar id, not `"primary"`) and unions their busy periods.
613
614**Before comparing** each `(start, end)` against your candidate slots, parse it
615to an absolute instant honoring the trailing offset — Google returns timed
616periods with a `Z` **or** a numeric offset (`2026-07-21T14:00:00+02:00`), and
617all-day blocks as a bare `YYYY-MM-DD` date. Truncating at the seconds and
618ignoring the offset shifts every busy interval by the offset (e.g. 2h in
619Zurich summer), so busy blocks miss the slots they should hide. Use
620`LibCalendar.rfc3339ToNanos` (a tested re-export of `mo:google-oauth/DateTime`)
621— it honors the offset and handles all-day dates — then overlap numerically. Do
622**not** hand-roll a parser that stops at the seconds.
623
624End to end, the whole availability flow lives in nanosecond instants and only
625touches text at the edges: anchor the window with `LibCalendar.rfc3339ToNanos`,
626build the candidate grid with plain integer arithmetic, filter with
627`LibCalendar.isSlotFree`, then format the survivors back with
628`LibCalendar.nanosToRfc3339` so they are ready to display and to pass straight to
629`createEvent` (whose `startDateTime` / `endDateTime` are RFC 3339 text). The grid
630below is a fixed UTC window; real working-hours / timezone policy is app-specific,
631but the parse → integer-math → format shape is the same:
632
633```motoko
634func availableSlots(
635 clientId : Text, clientSecret : Text, connection : LibCalendar.CalendarConnection,
636 caller : Principal, calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>,
637 windowStart : Text, // e.g. "2026-07-21T09:00:00Z"
638 slotCount : Nat, // number of consecutive slots to consider
639 slotMinutes : Nat, // slot length, e.g. 30
640) : async* [(Text, Text)] {
641 let slotNs = slotMinutes * 60 * 1_000_000_000;
642 let start0 = LibCalendar.rfc3339ToNanos(windowStart);
643 // Candidate grid of [s, s+slot) instants.
644 let candidates = Array.tabulate<(Int, Int)>(slotCount, func(i) {
645 let s = start0 + i * slotNs;
646 (s, s + slotNs);
647 });
648 let windowEnd = start0 + slotCount * slotNs;
649 let busy = await* LibCalendar.busyTimes(
650 clientId, clientSecret, connection, caller, calendarConnections,
651 windowStart, LibCalendar.nanosToRfc3339(windowEnd),
652 );
653 let free = Array.filter<(Int, Int)>(candidates, func(s) = LibCalendar.isSlotFree(s.0, s.1, busy));
654 Array.map<(Int, Int), (Text, Text)>(
655 free, func(s) = (LibCalendar.nanosToRfc3339(s.0), LibCalendar.nanosToRfc3339(s.1)),
656 );
657};
658```
659
660## 5. Available API surface
661
662### `google-oauth` (OAuth 2.0 mechanics)
663
664| Function | Purpose |
665| --- | --- |
666| `OAuth.urlEncode(text)` | RFC 3986 percent-encoding for form bodies |
667| `OAuth.parseTokenResponse(text)` | Parse Google token-endpoint JSON |
668| `OAuth.exchangeAuthorizationCode(...)` | Exchange auth code for tokens |
669| `OAuth.refreshAccessToken(...)` | Refresh an expired access token |
670| `OAuth.generateCodeVerifier()` | Generate PKCE `code_verifier` (on-chain randomness) |
671| `OAuth.computeCodeChallenge(verifier)` | Compute PKCE `code_challenge` (S256) |
672| `OAuth.buildAuthorizeUrl(...)` | Build the Google OAuth authorize URL |
673| `OAuth.getUserEmail(accessToken)` | Fetch the connected email via OIDC userinfo (needs only `openid email`) |
674
675### Availability math (`LibCalendar` re-exports of `mo:google-oauth/DateTime`)
676
677`lib/calendar.mo` re-exports these tested helpers, so call them as `LibCalendar.*`
678with no extra import. Times are absolute nanoseconds since the Unix epoch,
679matching `Time.now()`.
680
681| Function | Purpose |
682| --- | --- |
683| `LibCalendar.rfc3339ToNanos(text)` | Offset-aware RFC 3339 -> nanoseconds (honors `Z` / `±HH:MM`, bare dates) |
684| `LibCalendar.nanosToRfc3339(ns)` | Nanoseconds -> UTC RFC 3339 text (`…Z`), ready for `createEvent` |
685| `LibCalendar.overlaps(aStart, aEnd, bStart, bEnd)` | Half-open interval overlap test |
686| `LibCalendar.isSlotFree(slotStart, slotEnd, busy)` | Slot is free of every `(start, end)` RFC 3339 busy pair |
687
688### `googlecalendar-client` (Calendar REST API v3)
689
690The canonical actor above intentionally implements only upcoming-event listing
691and event creation; for availability/busy times use the FreeBusy helper in §4b.
692For another generated operation, keep bearer authentication and
693`is_replicated = ?false`, then apply the same single-refresh-retry pattern as
694`refreshIfNeeded`.
695
696The generated package also exposes:
697
698| Function | Module | Purpose |
699| --- | --- | --- |
700| `calendar_events_list` | EventsApi | List events on a calendar |
701| `calendar_events_get` | EventsApi | Get an event by id |
702| `calendar_events_insert` | EventsApi | Create an event |
703| `calendar_events_update` | EventsApi | Update an event (PUT) |
704| `calendar_events_patch` | EventsApi | Patch an event (PATCH) |
705| `calendar_events_delete` | EventsApi | Delete an event |
706| `calendar_events_move` | EventsApi | Move an event to another calendar |
707| `calendar_events_quickAdd` | EventsApi | Create event from text ("Lunch at noon") |
708| `calendar_events_instances` | EventsApi | List instances of a recurring event |
709| `calendar_freebusy_query` | FreebusyApi | Check free/busy across calendars |
710| `calendar_calendarList_list` | CalendarListApi | List user's calendars |
711| `calendar_calendarList_get` | CalendarListApi | Get a calendar list entry |
712| `calendar_calendars_get` | CalendarsApi | Get calendar metadata |
713| `calendar_calendars_insert` | CalendarsApi | Create a secondary calendar |
714
715## 6. Cycles and response sizes
716
717The `google-oauth` library uses `Call.httpRequest` from `mo:ic/Call`, which
718auto-computes and attaches the exact required cycles via the
719`ic0.cost_http_request` system API. No manual cycle budgeting is needed
720for token exchange or refresh calls.
721
722For `googlecalendar-client` calls, `defaultConfig.cycles = 30_000_000_000`
723(30B). A typical list/insert costs ~10–15B cycles. Set
724`max_response_bytes = ?2_000_000` for event list reads that may include
725large payloads.
726
727## 7. Things that will bite you
728
729- **`is_replicated = ?false`** — see §3. Non-negotiable.
730- **Google refresh tokens do NOT rotate.** Unlike X/Twitter, Google does
731 not issue a new `refresh_token` on each refresh. Keep the original
732 `refresh_token` and only persist the new `access_token`.
733- **Access tokens expire in 1 hour.** The `refreshIfNeeded` helper catches
734 HTTP 401, silently refreshes via `google-oauth.refreshAccessToken`, and
735 retries once. If the refresh also fails, surface "re-connect your account".
736- **Callback URI exact-match.** Every character (trailing slash, query
737 string, port) must match between the authorize URL and the redirect.
738 Google returns `redirect_uri_mismatch` otherwise. Use the fixed
739 `window.location.origin + "/connect/calendar"` for `redirectUri` — the same
740 value the settings page displays and the `/connect/calendar` route owns — and
741 register that exact URI on the Google Web client. Do not build it from
742 `window.location.pathname`, which varies by page.
743- **Pass the displayed value to `startCalendarOAuth` unchanged — never the raw
744 `*.icp0.io` canister URL.** A Caffeine app is served at several origins (the
745 `*-draft.caffeine.xyz` draft, the `*.caffeine.xyz` live domain, and the raw
746 `<canister-id>.icp0.io` URL). Compute the redirect URI in **one** shared
747 helper (`window.location.origin + "/connect/calendar"`) and use that same
748 helper both for the copyable field on the settings page and for the value
749 handed to `startCalendarOAuth`. If the value sent to Google (via
750 `startCalendarOAuth`) differs from what the settings page showed and the admin
751 registered — e.g. a build-time/config value or the `*.icp0.io` canister origin
752 — Google returns `redirect_uri_mismatch`.
753- **RFC 3339 timestamps.** Calendar uses RFC 3339 strings
754 (`2026-07-10T15:00:00-07:00`). For all-day events set
755 `EventDateTime.date` (`YYYY-MM-DD`) instead of `dateTime`.
756- **`createEvent` times need a zone.** The `dateTime` you pass to `createEvent`
757 MUST carry a UTC offset (`…Z` or `…+02:00`) or you MUST also set
758 `EventDateTime.timeZone` (an IANA name like `"Europe/Zurich"`). A bare
759 `2026-07-10T15:00:00` with neither is rejected by Google. Prefer sending an
760 offset-qualified string so the event lands at the intended wall-clock time.
761- **`calendarId = "primary"`** refers to the authenticated user's default
762 calendar. Named/shared calendars use their calendar-ID (an email-like
763 address).
764- **Availability = FreeBusy, not `events.list`.** For "am I free / busy" use
765 `calendar_freebusy_query` (§4b): one POST returns merged busy intervals with
766 recurrences expanded server-side. Rebuilding availability from `events.list`
767 means paging, expanding recurring events, and merging overlaps by hand — easy
768 to get wrong, and the classic cause of "the booking link shows me free when I'm
769 busy".
770- **`maxAttendees` and `maxResults` must be ≥ 1.** Google rejects `maxAttendees=0`
771 / `maxResults=0` with HTTP 400 (documented minimum is 1). The `listUpcomingEvents`
772 and `createEvent` recipes pass `maxAttendees = 10`; never pass `0` for these on
773 any events endpoint.
774- **FreeBusy responses are keyed by the *resolved* calendar ID, not the string
775 you queried.** When you call `calendar_freebusy_query` for `"primary"`, Google
776 resolves it and returns the `calendars` map keyed by the real calendar ID (the
777 user's email address), **not** the literal `"primary"`. Do **not** look up
778 `"primary"` in the response — that finds nothing and makes every slot look
779 free (a common availability bug). Instead, iterate over **every** calendar the
780 response returns and union all their `busy` intervals, then subtract those
781 from your candidate slots. Parse each interval's `start`/`end` as RFC 3339
782 allowing a trailing `Z` or a numeric offset (`+02:00`); compare instants, not
783 raw strings.
784- **Parse RFC 3339 with `LibCalendar.rfc3339ToNanos` (re-exported from
785 `mo:google-oauth/DateTime`) — do NOT re-implement it.** A hand-rolled parser
786 that forgets to subtract `'0'` (48) per digit reads `"2026"` as `55354`, so
787 every busy interval lands in the wrong year, overlap checks never match, and
788 availability is silently wrong — the code still compiles and never traps, so the
789 bug is invisible until a user is double-booked. Use the tested helper.
790- **HTTP 429 rate-limit.** Surface the error to the caller; never
791 silently retry a write inside the canister — a retry may create a
792 duplicate event.
793- **Don't expose the access token.** `calendarConnections` is read only by
794 `Map.get(calendarConnections, ..., caller)` inside API calls. No
795 `getMyCalendarConnection`, no `getMyAccessToken`, no iterator. A leaked
796 bearer is a per-user account compromise.
797- **`alt = #json`** for all Calendar API v3 calls. Leave optional string
798 parameters `""` and `prettyPrint = false`.
799- **API query parameters are plain positional values, not `?T` — never pass
800 `null` for one.** The client's function parameters are `Text` / `Bool` / enum /
801 `Nat` (e.g. `alt`, `fields`, `prettyPrint`); pass real values like `#json`,
802 `""`, `false`, `10` — `null` will not type-check. (Respect each param's
803 documented minimum: `maxAttendees` / `maxResults` must be ≥ 1, see below.) Only
804 **model** values (`Event`, `EventDateTime`, `FreeBusyRequest`) are optional
805 `?T`.
806- **Combined Gmail + Calendar apps: request the scope union, and learn the
807 address via `OAuth.getUserEmail`.** The union of `openid email` + `.../calendar`
808 + `.../gmail.send` covers availability, sending, and the connected address (via
809 OIDC userinfo) — no `gmail.readonly` needed unless the app actually reads mail.
810 Never drop a scope when merging recipes — see "Combined Gmail + Calendar apps".
811- **Build `Event` / `EventDateTime` with `init {}` then record-update**
812 the fields you need — all fields are optional (`?T`); leave the rest null.
813- **PATCH/PUT/DELETE are forced non-replicated** in the generated client
814 (the `googlecalendar-client` sets `is_replicated = ?false` on these
815 methods automatically). For GET/POST, set it explicitly in your `Config`.
816
817# Frontend
818
819Every build using this skill MUST ship all four items below. (If the app
820**also** uses the Gmail connector, follow "Combined Gmail + Calendar apps" below
821instead — it replaces `/settings/calendar` + `/connect/calendar` with one shared
822`/settings/google` + `/connect/google`. The requirements below still apply; only
823the two paths change.) These are **acceptance criteria, not suggestions** —
824verify each before the build is done. These three are the requirements builds
825skip, and any one missing makes the connector **broken, not merely
826incomplete**:
827
828- **The credentials page exists and is reachable.** The app MUST have the
829 `/settings/calendar` page with Client ID/Secret inputs (item 2), and a signed-in
830 admin MUST be able to reach it — via a nav link or the not-configured prompt on
831 the connect page. A "Connect Google Calendar" button with no page to enter
832 credentials is the most common failure and leaves the connector unusable.
833- **The admin settings page displays the literal, copyable redirect URI.** Not a
834 `<your-domain>` placeholder, not "your app URL + /connect/calendar" as text for
835 the admin to assemble — the actual string
836 `window.location.origin + "/connect/calendar"` rendered in a read-only field
837 the admin can copy. Concretely: an app served from `https://my-app.caffeine.xyz`
838 must show a field containing exactly
839 `https://my-app.caffeine.xyz/connect/calendar` and nothing else. Without it the
840 admin cannot register the URI in Google and every connection fails.
841- **`/connect/calendar` is a real route that handles Google's callback** — not a
842 button-only page. If it falls through to a catch-all/home redirect, or calls
843 `completeCalendarOAuth` before the authenticated actor is ready, the connection
844 silently fails and the app shows "not connected".
845
8461. **A login flow — required.** Calendar cannot work without a non-anonymous
847 caller; the per-user OAuth handshake stores tokens keyed by
848 `caller : Principal`, and the admin credential setter gates on
849 `#admin`. The login flow comes from
850 [`extension-authorization`](../extension-authorization/SKILL.md):
851 `useInternetIdentity`, login/logout buttons, the `useActor` plumbing
852 that injects the authenticated identity into every backend call.
853
8542. **An admin settings page** — `/settings/calendar` (admin-gated). This
855 page is required; a Calendar build is incomplete without it:
856 - Show a "How to get your Google credentials" panel before the credential
857 inputs. Reassure the admin it is a one-time, ~5-minute setup, and walk
858 through these numbered steps (the agent's completion message must repeat
859 the same steps):
860 1. open the [Google Cloud Console](https://console.cloud.google.com) and
861 sign in with any Google account;
862 2. create or pick a project;
863 3. enable the **Google Calendar API** (APIs & Services → Library → search
864 "Google Calendar API" → Enable);
865 4. configure the **OAuth consent screen** (APIs & Services → OAuth consent
866 screen → **External**; set app name, support email, developer email;
867 Google's default scopes are fine);
868 5. create an **OAuth client ID** of type **Web application** (APIs &
869 Services → Credentials → Create Credentials → OAuth client ID);
870 6. under **Authorized redirect URIs**, add the exact value from the
871 copyable field on this page;
872 7. copy the resulting **Client ID** and **Client Secret** into the inputs
873 below and save.
874 Include a convenience link that opens the Google Cloud Console.
875 - Render the actual URI in a read-only, copyable field using one shared
876 helper:
877 `const calendarRedirectUri = () => window.location.origin + "/connect/calendar";`.
878 For example, if the app is open at `https://my-app.caffeine.xyz`, the
879 displayed value is `https://my-app.caffeine.xyz/connect/calendar`. Never
880 show only `<app-domain>` or ask the administrator to infer the URI.
881 - Two password-inputs bound to `setCalendarCredentials(clientId, clientSecret)`.
882 Submit on enter; clear inputs on success.
883 - Status indicator driven by `isCalendarConfigured()` (returns `Bool`).
884 Show "Configured" / "Not configured" — never display the credentials.
885 - **Make this page reachable.** The app's main navigation (the shared Layout)
886 MUST link to this page for admins — show the link when `isCallerAdmin` is
887 true, hide it otherwise (via
888 [`extension-authorization`](../extension-authorization/SKILL.md)). Add that
889 link wherever the nav is defined, not inside this page. A `/settings/calendar`
890 route with no way to reach it is a broken build. Do not rely on the nav
891 alone: the not-configured prompt below is the primary way users discover
892 setup is needed.
893
8943. **A "Connect Calendar" and callback page** — `/connect/calendar` (any
895 signed-in user). This dedicated page must catch and handle Google's redirect
896 after consent; it is not only a page with a connect button:
897 - **Handle the not-configured case for everyone.** `isCalendarConfigured()` is
898 a public query (any signed-in user may call it). When it returns `false`, do
899 not show a dead connect button. Admins see a link to `/settings/calendar` to
900 enter credentials. Non-admins must see an explanation, not a dead end — e.g.
901 "Google Calendar isn't set up yet — the app's administrator needs to add
902 Google credentials in Settings." Enable the "Connect Google Calendar" button
903 only once configured.
904 - "Connect Google Calendar" button bound to
905 `startCalendarOAuth(calendarRedirectUri())`. Redirect the browser to the
906 URL returned by the canister. Do not derive the callback from an
907 arbitrary current pathname; the fixed `/connect/calendar` route and the
908 settings-page URI must be identical.
909 - Register `/connect/calendar` as a real application route. It must catch
910 the Google callback and must not fall through to a catch-all redirect,
911 layout default, or home page before processing it.
912 - On the return leg, read `error`, `code`, and `state` from
913 `URLSearchParams`. If `error` is present, show the failed/declined
914 connection state and do not call the canister. Only when both `code`
915 and `state` are present, call and **await**
916 `completeCalendarOAuth(code, state)` before navigating anywhere or
917 clearing the URL. Keep a visible "Connecting Google Calendar…" state
918 while it is pending. Do not replace the route, redirect to the home
919 page, or discard the query parameters first — that loses the one-time
920 code and leaves the user disconnected.
921 - **Wait for actor readiness before the one-time callback call.** The page
922 must wait for `useInternetIdentity().isAuthenticated` and
923 `useActor(createActor)` to provide a non-null, non-fetching actor before
924 calling `completeCalendarOAuth`. Do not set a `startedRef`/one-shot guard
925 until then: on first render the actor is often unavailable, and an
926 "Actor not ready" failure otherwise consumes the only retry while the
927 authorization code is still in the URL.
928 - After either terminal path, call `history.replaceState` to remove the
929 OAuth query parameters. This prevents a page refresh from reusing a
930 one-time authorization code.
931 - Status driven by `isMyCalendarConnected()` (returns `Bool`).
932 - Optional "Disconnect Calendar" button bound to `disconnectMyCalendar()`.
933
9344. **Calendar UI** — the main page shows upcoming events. When
935 `isCalendarConfigured()` is `false` and the caller is an admin, render a
936 "Set up Google Calendar" link to `/settings/calendar` so the credentials
937 page is discoverable, not just reachable. Pass the current
938 time as the RFC 3339 `timeMin` value, `""` for an open-ended `timeMax`:
939 `listUpcomingEvents(new Date().toISOString(), "", 10)`. To bound a single day
940 (e.g. "meetings tomorrow"), pass both — the local start of the day and the
941 start of the next day, each RFC 3339 with an offset — and count only entries
942 whose `isAllDay` is false and `transparency` is not `"transparent"` and
943 `eventType` is `"default"` (that filters out all-day, free, out-of-office,
944 and working-location markers). This is required when
945 using `singleEvents = true` and `orderBy = startTime`. Also include a
946 "create event" form. `datetime-local` values have no offset, so convert
947 each browser-local value to an RFC 3339 instant before calling the actor:
948 `createEvent(summary, new Date(startInput).toISOString(),
949 new Date(endInput).toISOString())`.
950 When `isMyCalendarConnected()` is `false`, render an inline
951 "Connect Google Calendar" link to `/connect/calendar`.
952
953Suggested route layout:
954
955```
956/ → Main UI (upcoming events + create form)
957/settings/calendar → Admin credential config (admin-only)
958/connect/calendar → Per-user OAuth handshake (any signed-in user)
959# If the app ALSO uses Gmail: drop the two routes above and use a single
960# /settings/google + /connect/google — see "Combined Gmail + Calendar apps".
961```
962
963## Combined Gmail + Calendar apps
964
965When an app uses both connectors, build **one** shared Google connection, not
966two (an auth code is single-use, so two flows would force two consent screens).
967Frontend:
968
969- **One admin page `/settings/google`** — a single Client ID / Client Secret
970 form, one `isGoogleConfigured` status, and one copyable redirect-URI field
971 showing exactly `window.location.origin + "/connect/google"`.
972- **One connect route `/connect/google`** — the same real callback route the
973 Frontend section above requires: it renders "Connect Google", catches the
974 redirect, waits for actor readiness, then calls completion once. No second
975 callback route.
976- **Do NOT build** `/settings/gmail`, `/connect/gmail`, `/settings/calendar`, or
977 `/connect/calendar`. Every other Frontend requirement above still applies —
978 only these paths change.
979
980Backend — write the shared flow **once** (it replaces both per-connector OAuth
981flows). It is the same shape as the per-connector `startAuthorize` /
982`exchangeCode` / refresh functions, with these exact differences:
983
984- One `#admin`-gated config setter storing a single Client ID/Secret.
985- `SCOPES` = the union below — both APIs in one consent.
986- `completeGoogleOAuth(code, state)` learns the connected email via
987 `OAuth.getUserEmail` (OIDC userinfo — needs only `openid email`, not
988 `gmail.readonly`) and stores one connection
989 `{ accessToken; refreshToken; emailAddress }` in a single
990 `Map<Principal, GoogleConnection>`.
991- Gmail sends and Calendar calls each build **their own** client `Config` from
992 that one `accessToken`, each keeping its single-refresh-on-401 retry.
993- Keep the connection and client config in **one** shared state value and pass it
994 as a parameter to both the Gmail and Calendar mixins, so both read and write the
995 same connection (see the `writing-motoko` mixins rule).
996
997```motoko filepath=src/backend/google.mo
998let SCOPES : Text =
999 "openid email " // learn the address via userinfo
1000 # "https://www.googleapis.com/auth/gmail.send "
1001 # "https://www.googleapis.com/auth/calendar";
1002// Add "https://www.googleapis.com/auth/gmail.readonly " ONLY if the app reads mail.
1003```
1004
1005Wire it as **one** connection shared by both services — declare the config,
1006connection map, and pending-flow map once and pass the **same** bindings to
1007every mixin. The Gmail and Calendar messaging mixins do **not** declare their own
1008config or connection; they receive the shared `googleConfig` and
1009`googleConnections` (config is needed for the refresh-on-401 retry):
1010
1011```motoko filepath=src/backend/main.mo
1012actor {
1013 let accessControlState = AccessControl.initState();
1014 include MixinAuthorization(accessControlState, null);
1015
1016 // ONE shared credential + connection state for both services.
1017 let googleConfig = { var clientId : Text = ""; var clientSecret : Text = "" };
1018 let googleConnections : Map.Map<Principal, Google.Connection> = Map.empty();
1019 let pendingGoogleFlows : Map.Map<Principal, Google.PendingOAuth> = Map.empty();
1020
1021 include MixinGoogleConfig(accessControlState, googleConfig); // setGoogleCredentials / isGoogleConfigured (#admin-gated setter)
1022 include MixinGoogleOAuth(googleConfig, googleConnections, pendingGoogleFlows); // startGoogleOAuth / completeGoogleOAuth, SCOPES = union above
1023 include MixinGmailMessaging(googleConfig, googleConnections); // sendEmail — refresh-on-401 needs config; reads the shared connection
1024 include MixinCalendarMessaging(googleConfig, googleConnections); // calendar calls — same shared config + connection
1025};
1026```
1027
1028Do not give Gmail and Calendar separate config/connection state or separate
1029OAuth flows — one auth code is single-use, and separate state desyncs (see the
1030`writing-motoko` mixins rule).
1031
1032Enable both APIs on the one OAuth client and register only the single
1033`.../connect/google` redirect URI. Split into two separate panels **only** if
1034the user explicitly asks to connect two different Google accounts.
1035
1036## Common to all variants
1037
1038- **Sign-in is required** for every Calendar-related route. Wire the
1039 `/settings/...` and the connect route (`/connect/calendar`, or
1040 `/connect/google` in a combined app) through
1041 [`extension-authorization`](../extension-authorization/SKILL.md)'s
1042 auth guard (`useInternetIdentity` + redirect when `!isAuthenticated`).
1043- **The frontend never persists tokens.** No `localStorage`, no
1044 `IndexedDB`, no cookies — the canister mediates everything. The browser
1045 only ever sees `Bool` status flags and the OAuth redirect URLs.
1046- **The OAuth `state` parameter is canister-generated and validated.** The
1047 canister stores a random nonce with the pending verifier and callback URI.
1048 The frontend must pass both `code` and `state` to the completion call
1049 (`completeCalendarOAuth`, or `completeGoogleOAuth` in a combined app);
1050 it never creates or modifies either value.
1051- **The calendar UI is trivial:** a list of upcoming events, a create-event
1052 form with summary + start/end datetime inputs. No client-side Google SDK,
1053 no token handling, no JSON serialization — the canister is the Calendar client.
1054
1055## Related
1056
1057- [`mops add googlecalendar-client@0.1.4`](https://mops.one/googlecalendar-client) — Calendar REST API v3 bindings.
1058- [`mops add google-oauth@0.2.0`](https://mops.one/google-oauth) — Google OAuth 2.0 library (token exchange, refresh, PKCE, `getUserEmail` userinfo, `DateTime` RFC 3339 helpers).
1059- [Google OAuth 2.0 for Web Server Applications](https://developers.google.com/identity/protocols/oauth2/web-server) — Web-client redirect URI and authorization-code flow reference.
1060- [Google Calendar API v3 reference](https://developers.google.com/calendar/api/v3/reference) — what `googlecalendar-client` wraps.
1061- [RFC 7636 — Proof Key for Code Exchange](https://datatracker.ietf.org/doc/html/rfc7636) — PKCE spec.
1062- [extension-authorization](../extension-authorization/SKILL.md) — **required prerequisite**. Provides Internet Identity login, `useInternetIdentity` / `useActor` frontend plumbing, and the `#admin` role gate.
1063- [connector-googlemail](../connector-googlemail/SKILL.md) — sister connector using the same `google-oauth` library for Gmail.