首页/AI 智能体外部交互/connector-googlecalendar
C

connector-googlecalendar

by @caffeinelabsv
4.6(120)

此技能让 AI Agent 能代表已登录用户连接 Google Calendar,实现事件查询、创建及空闲时段检查,简化日程管理流程。

google-calendaroauthcalendar-integrationavailability-checkGitHub
安装方式
npx skills add https://github.com/caffeinelabs/skills --skill connector-googlecalendar
compare_arrows

Before / After 效果对比

1 组
使用前

用户需要手动查看日历、比对多个时间段,平均耗时15分钟。

使用后

此Skill自动调用FreeBusy API,1秒返回结果,效率提升15倍。

SKILL.md

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 intentPlatform capability
Connect and list upcoming eventsgooglecalendar-client + google-oauth
Create calendar eventsgooglecalendar-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:

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

1. Add dependencies

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

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

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

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

Google Cloud Console setup

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

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

OAuth scopes

ScopePurpose
https://www.googleapis.com/auth/calendarFull read/write access to calendars
https://www.googleapis.com/auth/calendar.eventsRead/write access to events only
https://www.googleapis.com/auth/calendar.readonlyRead-only access to calendars
https://www.googleapis.com/auth/calendar.events.readonlyRead-only access to events

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

Storing tokens

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

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

Google refresh tokens do NOT rotate

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

3. is_replicated = ?false is REQUIRED

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

→ Always: is_replicated = ?false on every Config.

4. Canonical layout

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

The example spans four files:

  • src/backend/main.mo — the actor: state + 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 : 

...

用户评价 (0)

发表评价

效果
易用性
文档
兼容性

暂无评价

统计数据

安装量21.4K
评分4.6 / 5.0
版本
更新日期2026年10月23日
对比案例1 组

用户评分

4.6(120)
5
37%
4
43%
3
13%
2
5%
1
2%

为此 Skill 评分

0.0

兼容平台

🤖claude-code

时间线

创建2026年7月26日
最后更新2026年10月23日
🎁 Agent 知识卡片
调研问卷