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-clientmops 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-oauthmops package — Google OAuth 2.0 token exchange, refresh, PKCE, and percent-encoding. This is the library that eliminates hand-rolledhttp_requesttooauth2.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_tokenkeyed bycaller : 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.1
mops add caffeineai-authorization@1.0.1
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_verifierandcode_challenge(viagoogle-oauth). - Builds the Google authorize URL (via
google-oauth.buildAuthorizeUrl). - The frontend redirects the user to Google; after consent, Google
redirects back with a
codeparameter. - The canister exchanges the code for tokens (via
google-oauth.exchangeAuthorizationCode) — on-chain, non-replicated. - The canister stores
access_token+refresh_tokenkeyed bycaller. - 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 onnot caller.isAnonymous(). Do not add any endpoint that returnsaccess_token/refresh_token/ the fullCalendarConnection. - Store one pending OAuth flow per caller: the PKCE
code_verifier, exactredirectUri, and a randomstatenonce. 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-oauthglue.
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.AccessControlState;
include MixinAuthorization(accessControlState, null);
let calendarConfig : {
var clientId : Text;
var clientSecret : Text;
};
include MixinCalendarConfig(accessControlState, calendarConfig);
let calendarConnections : Map.Map<Principal, LibCalendar.CalendarConnection>;
let pendingCalendarFlows : Map.Map<Principal, LibCalendar.PendingOAuth>;
include MixinCalendarMessaging(calendarConfig, calendarConnections, pendingCalendarFlows);
};
The migration chain head:
import Map "mo:core/Map";
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type CalendarConnection = {
accessToken : Text;
refreshToken : Text;
};
type PendingOAuth = {
codeVerifier : Text;
redirectUri : Text;
state : Text;
};
type NewActor = {
accessControlState : AccessControl.AccessControlState;
calendarConfig : { var clientId : Text; var clientSecret : Text };
calendarConnections : Map.Map<Principal, CalendarConnection>;
pendingCalendarFlows : Map.Map<Principal, PendingOAuth>;
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
calendarConfig = { var clientId = ""; var clientSecret = "" };
calendarConnections = Map.empty<Principal, CalendarConnection>();
pendingCalendarFlows = Map.empty<Principal, PendingOAuth>();
};
};
};
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 Error "mo:core/Error";
import Map "mo:core/Map";
import Nat64 "mo:core/Nat64";
import Principal "mo:core/Principal";
import PureMap "mo:core/pure/Map";
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 = tokens.refreshToken
?? 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 {};
};
tokens.accessToken ?? 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.values()) {
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,
);
};
created.id ?? "";
};
func eventSummariesOf(events : Events) : EventSummaryList {
let items = events.items ?? [];
Array.map<Event, EventSummary>(items, func(e : Event) : EventSummary = {
id = e.id ?? "";
summary = e.summary ?? "(no title)";
start = switch (e.start) {
case (?dt) dt.dateTime ?? dt.date ?? "";
case null "";
};
end = switch (e.end) {
case (?dt) dt.dateTime ?? dt.date ?? "";
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 = e.transparency ?? "";
eventType = e.eventType ?? "";
});
};
};
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_tokenon each refresh. Keep the originalrefresh_tokenand only persist the newaccess_token. - Access tokens expire in 1 hour. The
refreshIfNeededhelper catches HTTP 401, silently refreshes viagoogle-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_mismatchotherwise. Use the fixedwindow.location.origin + "/connect/calendar"forredirectUri— the same value the settings page displays and the/connect/calendarroute owns — and register that exact URI on the Google Web client. Do not build it fromwindow.location.pathname, which varies by page. - Pass the displayed value to
startCalendarOAuthunchanged — never the raw*.icp0.iocanister URL. A Caffeine app is served at several origins (the*-draft.caffeine.xyzdraft, the*.caffeine.xyzlive domain, and the raw<canister-id>.icp0.ioURL). 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 tostartCalendarOAuth. If the value sent to Google (viastartCalendarOAuth) differs from what the settings page showed and the admin registered — e.g. a build-time/config value or the*.icp0.iocanister origin — Google returnsredirect_uri_mismatch. - RFC 3339 timestamps. Calendar uses RFC 3339 strings
(
2026-07-10T15:00:00-07:00). For all-day events setEventDateTime.date(YYYY-MM-DD) instead ofdateTime. createEventtimes need a zone. ThedateTimeyou pass tocreateEventMUST carry a UTC offset (…Zor…+02:00) or you MUST also setEventDateTime.timeZone(an IANA name like"Europe/Zurich"). A bare2026-07-10T15:00:00with 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" usecalendar_freebusy_query(§4b): one POST returns merged busy intervals with recurrences expanded server-side. Rebuilding availability fromevents.listmeans 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". maxAttendeesandmaxResultsmust be ≥ 1. Google rejectsmaxAttendees=0/maxResults=0with HTTP 400 (documented minimum is 1). ThelistUpcomingEventsandcreateEventrecipes passmaxAttendees = 10; never pass0for these on any events endpoint.- FreeBusy responses are keyed by the resolved calendar ID, not the string
you queried. When you call
calendar_freebusy_queryfor"primary", Google resolves it and returns thecalendarsmap 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 theirbusyintervals, then subtract those from your candidate slots. Parse each interval'sstart/endas RFC 3339 allowing a trailingZor a numeric offset (+02:00); compare instants, not raw strings. - Parse RFC 3339 with
LibCalendar.rfc3339ToNanos(re-exported frommo:google-oauth/DateTime) — do NOT re-implement it. A hand-rolled parser that forgets to subtract'0'(48) per digit reads"2026"as55354, 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.
calendarConnectionsis read only byMap.get(calendarConnections, ..., caller)inside API calls. NogetMyCalendarConnection, nogetMyAccessToken, no iterator. A leaked bearer is a per-user account compromise. alt = #jsonfor all Calendar API v3 calls. Leave optional string parameters""andprettyPrint = false.- API query parameters are plain positional values, not
?T— never passnullfor one. The client's function parameters areText/Bool/ enum /Nat(e.g.alt,fields,prettyPrint); pass real values like#json,"",false,10—nullwill not type-check. (Respect each param's documented minimum:maxAttendees/maxResultsmust 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 ofopenid email+.../calendar.../gmail.sendcovers availability, sending, and the connected address (via OIDC userinfo) — nogmail.readonlyneeded unless the app actually reads mail. Never drop a scope when merging recipes — see "Combined Gmail + Calendar apps".
- Build
Event/EventDateTimewithinit {}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-clientsetsis_replicated = ?falseon these methods automatically). For GET/POST, set it explicitly in yourConfig.
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/calendarpage 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 stringwindow.location.origin + "/connect/calendar"rendered in a read-only field the admin can copy. Concretely: an app served fromhttps://my-app.caffeine.xyzmust show a field containing exactlyhttps://my-app.caffeine.xyz/connect/calendarand nothing else. Without it the admin cannot register the URI in Google and every connection fails. /connect/calendaris a real route that handles Google's callback — not a button-only page. If it falls through to a catch-all/home redirect, or callscompleteCalendarOAuthbefore 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 fromextension-authorization:useInternetIdentity, login/logout buttons, theuseActorplumbing 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
…(truncated)