google-calendar
Drive Google Calendar via curl + jq. The user's OAuth bearer token
is in $GOOGLE_CALENDAR_TOKEN; every call needs it as
Authorization: Bearer $GOOGLE_CALENDAR_TOKEN. At minimum the token
carries calendar.readonly plus the identity scopes
(openid email profile); if the user opted in to write at install
time it also carries the broader calendar scope (read + write).
The Calendar API returns standard JSON; failures surface as
{"error": {"code": 401|403|..., "message": "..."}} — show that
error verbatim. 401 means the token expired (re-install). 403 insufficientPermissions on a write means the user only granted
calendar.readonly — ask them to re-install the connector with the
read+write box checked.
Always start with users/me/calendarList to learn which calendars
the account can see (the user's primary plus any subscribed / shared
ones), AND with users/me/settings/timezone so you render times in
the user's local zone instead of UTC.
Before any destructive write (creating, moving, or cancelling an
event that has attendees) show the exact event details and ask the
user to confirm. When attendees are involved, also confirm whether
they want Google to email the attendees — that's controlled by the
sendUpdates query parameter.
Optional: Google Workspace CLI (gws) for agenda + create
gws is Google's official CLI
(not officially supported — community-maintained on the googleworkspace
org). It dynamically builds its command surface from Google's Discovery
Document, exits non-zero on API errors, and ships hand-crafted helper
commands (prefixed +) for time-aware workflows.
Use gws for two specific cases:
+agendareads the user's account timezone fromSettings.timezone(cached for 24 h) and renders today's events in that zone, so you don't have to fetch the timezone yourself before formatting times.+insertshapes the create-event JSON for you (attendees, sendUpdates, reminders) so a one-line invocation produces a well-formed request.
For everything else (events.list / patch / move / delete, freebusy, calendarList) the curl recipes below are equivalent and shorter — stay on those.
Install
npm install -g @googleworkspace/cli # or: brew install googleworkspace-cli
# Pre-built binaries also at https://github.com/googleworkspace/cli/releases
gws --version
Auth
gws reads its OAuth bearer token from the GOOGLE_WORKSPACE_CLI_TOKEN
environment variable. The Calendar token used in this skill is in
$GOOGLE_CALENDAR_TOKEN, so re-export it once at the top of every shell
block that calls gws:
export GOOGLE_WORKSPACE_CLI_TOKEN="$GOOGLE_CALENDAR_TOKEN"
Agenda + create
# Today on the primary calendar, in the account's own timezone
gws calendar +agenda
# Today / week, with explicit overrides
gws calendar +agenda --today --tz America/New_York
gws calendar +agenda --range week
# Create an event (auto-shapes attendees + sendUpdates JSON)
gws calendar +insert --calendar primary \
--json '{
"summary":"Standup",
"start":{"dateTime":"2026-05-06T10:00:00-04:00"},
"end": {"dateTime":"2026-05-06T10:30:00-04:00"},
"attendees":[{"email":"alice@example.com"}]
}' \
--params '{"sendUpdates":"all"}'
Both helpers exit non-zero with a structured JSON error on stderr if
Google rejects the request — surface that verbatim. +insert against
attendees requires the broader calendar scope; on 403 insufficientPermissions ask the user to re-install with read+write
checked.
Recipes
Verify auth + discover calendars (always run first)
# Account confirmation + calendars the user can read
curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/calendarList" \
| jq '.items[] | {id, summary, primary, accessRole, timeZone}'
# User's preferred display zone (use this when formatting times)
curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/settings/timezone" \
| jq -r .value
The id of each calendar (primary, or an email-shaped id like
team-monday@group.calendar.google.com) is what subsequent
calendars/{id}/events calls take.
Today's agenda on the primary calendar
TZ=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/settings/timezone" | jq -r .value)
TODAY=$(TZ=$TZ date +%Y-%m-%d)
START="${TODAY}T00:00:00Z"
END="${TODAY}T23:59:59Z"
curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
--get "https://www.googleapis.com/calendar/v3/calendars/primary/events" \
--data-urlencode "timeMin=$START" \
--data-urlencode "timeMax=$END" \
--data-urlencode 'singleEvents=true' \
--data-urlencode 'orderBy=startTime' \
--data-urlencode "timeZone=$TZ" \
| jq '.items[] | {summary, start: (.start.dateTime // .start.date), end: (.end.dateTime // .end.date), location, attendees: [.attendees[]?.email], hangout: .hangoutLink, status, htmlLink}'
singleEvents=true flattens recurring meetings into individual
instances — almost always what you want for an agenda. Without it,
you'd get the recurrence rule once and have to expand it client-side.
This week's meetings (Mon–Sun)
TZ=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/settings/timezone" | jq -r .value)
# Bash date math: Monday-of-this-week
MON=$(TZ=$TZ date -d "$(TZ=$TZ date +%Y-%m-%d) -$(($(TZ=$TZ date +%u) - 1)) days" +%Y-%m-%d 2>/dev/null \
|| TZ=$TZ date -v-mondayw +%Y-%m-%d) # macOS fallback
SUN=$(TZ=$TZ date -d "$MON +6 days" +%Y-%m-%d 2>/dev/null \
|| TZ=$TZ date -v+6d -j -f %Y-%m-%d "$MON" +%Y-%m-%d)
curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
--get "https://www.googleapis.com/calendar/v3/calendars/primary/events" \
--data-urlencode "timeMin=${MON}T00:00:00Z" \
--data-urlencode "timeMax=${SUN}T23:59:59Z" \
--data-urlencode 'singleEvents=true' \
--data-urlencode 'orderBy=startTime' \
| jq -r '.items[] | "\(.start.dateTime // .start.date)\t\(.summary)\t\((.attendees // []) | length) attendees"'
Search events by query
Q='quarterly review'
curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
--get "https://www.googleapis.com/calendar/v3/calendars/primary/events" \
--data-urlencode "q=$Q" \
--data-urlencode 'singleEvents=true' \
--data-urlencode 'maxResults=20' \
| jq '.items[] | {start: .start.dateTime, summary, htmlLink}'
q matches against summary, description, location, attendee emails,
and creator/organizer.
Get one event's full details (incl. attendees, location, link)
EVENT_ID='abc123def4567890ghijklmnop'
curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/calendars/primary/events/$EVENT_ID" \
| jq '{summary, start, end, location, description, attendees, organizer, hangoutLink, conferenceData}'
Free / busy across multiple calendars (next 7 days)
TZ=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/settings/timezone" | jq -r .value)
NOW=$(TZ=$TZ date -u +%Y-%m-%dT%H:%M:%SZ)
NEXT_WEEK=$(TZ=$TZ date -u -d "+7 days" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null \
|| TZ=$TZ date -u -v+7d +%Y-%m-%dT%H:%M:%SZ)
cat > /tmp/freebusy.json <<JSON
{
"timeMin": "$NOW",
"timeMax": "$NEXT_WEEK",
"timeZone": "$TZ",
"items": [
{"id": "primary"},
{"id": "team-monday@group.calendar.google.com"}
]
}
JSON
curl -sS -X POST -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
-H 'Content-Type: application/json' \
--data @/tmp/freebusy.json \
"https://www.googleapis.com/calendar/v3/freeBusy" \
| jq '.calendars'
Each calendar's response is {"busy": [{"start": "...", "end": "..."}]}
— gaps between are free.
List events on a non-primary calendar
CAL_ID='team-monday@group.calendar.google.com'
# URL-encode the @ in the path
CAL_ENCODED=$(printf %s "$CAL_ID" | jq -sRr @uri)
curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
--get "https://www.googleapis.com/calendar/v3/calendars/$CAL_ENCODED/events" \
--data-urlencode 'singleEvents=true' \
--data-urlencode 'orderBy=startTime' \
--data-urlencode 'maxResults=20' \
| jq '.items[] | {start: .start.dateTime, summary}'
Pagination
PAGE_TOKEN=''
while : ; do
RESP=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
--get "https://www.googleapis.com/calendar/v3/calendars/primary/events" \
--data-urlencode 'singleEvents=true' \
--data-urlencode 'orderBy=startTime' \
--data-urlencode 'maxResults=250' \
${PAGE_TOKEN:+--data-urlencode "pageToken=$PAGE_TOKEN"})
echo "$RESP" | jq -c '.items[]?'
PAGE_TOKEN=$(echo "$RESP" | jq -r '.nextPageToken // empty')
[ -z "$PAGE_TOKEN" ] && break
done
Write recipes
These all need the broader calendar scope. If the user only granted
calendar.readonly you'll get 403 insufficientPermissions —
surface that and ask them to re-install with the read+write box
checked. Always echo the event summary, time and attendee list
back to the user before creating or cancelling anything.
Create a single event (with optional attendees + Google Meet link)
TZ=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/settings/timezone" | jq -r .value)
cat > /tmp/_cal_event.json <<JSON
{
"summary": "Sync — Q2 OKR review",
"location": "Online",
"description": "Drafted by AceDataCloud.",
"start": {"dateTime": "2026-05-12T10:00:00", "timeZone": "$TZ"},
"end": {"dateTime": "2026-05-12T10:30:00", "timeZone": "$TZ"},
"attendees": [
{"email": "alice@example.com"},
{"email": "bob@example.com"}
],
"reminders": {"useDefault": true},
"conferenceData": {
"createRequest": {
"requestId": "meet-$(date +%s)",
"conferenceSolutionKey": {"type": "hangoutsMeet"}
}
}
}
JSON
# sendUpdates: 'all' = email all attendees; 'externalOnly' = only non-org; 'none' = silent
curl -sS -X POST -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
-H 'Content-Type: application/json' \
--data @/tmp/_cal_event.json \
"https://www.googleapis.com/calendar/v3/calendars/primary/events?conferenceDataVersion=1&sendUpdates=all" \
| jq '{id, htmlLink, hangoutLink, summary, start, end, attendees}'
Drop the conferenceData block if the user didn't ask for a Meet
link — it'll fall back to a plain event.
Create a recurring event
TZ=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/settings/timezone" | jq -r .value)
cat > /tmp/_cal_recur.json <<JSON
{
"summary": "Weekly 1:1",
"start": {"dateTime": "2026-05-12T15:00:00", "timeZone": "$TZ"},
"end": {"dateTime": "2026-05-12T15:30:00", "timeZone": "$TZ"},
"recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=TU;COUNT=12"]
}
JSON
curl -sS -X POST -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
-H 'Content-Type: application/json' \
--data @/tmp/_cal_recur.json \
"https://www.googleapis.com/calendar/v3/calendars/primary/events" \
| jq '{id, recurrence, summary}'
RRULE follows RFC 5545. Common patterns: FREQ=DAILY, FREQ=WEEKLY;BYDAY=MO,WE,FR,
FREQ=MONTHLY;BYMONTHDAY=15. Add UNTIL=20261231T235959Z or COUNT=12
for a hard stop.
Update an existing event (PATCH — partial update)
EVENT_ID='abc123def4567890ghijklmnop'
curl -sS -X PATCH -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
-H 'Content-Type: application/json' \
--data '{"location":"Conference Room 4","description":"Now in-person."}' \
"https://www.googleapis.com/calendar/v3/calendars/primary/events/$EVENT_ID?sendUpdates=all" \
| jq '{id, summary, location, description}'
PATCH only changes the fields you send; PUT replaces the entire
event payload. Prefer PATCH.
Reschedule an event
EVENT_ID='abc123def4567890ghijklmnop'
TZ=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/users/me/settings/timezone" | jq -r .value)
cat > /tmp/_cal_resched.json <<JSON
{
"start": {"dateTime": "2026-05-12T14:00:00", "timeZone": "$TZ"},
"end": {"dateTime": "2026-05-12T14:30:00", "timeZone": "$TZ"}
}
JSON
curl -sS -X PATCH -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
-H 'Content-Type: application/json' \
--data @/tmp/_cal_resched.json \
"https://www.googleapis.com/calendar/v3/calendars/primary/events/$EVENT_ID?sendUpdates=all" \
| jq '{id, summary, start, end}'
Add or change attendees
Google requires you to send the complete attendee list when patching attendees — fetch the current list, mutate, send back:
EVENT_ID='abc123def4567890ghijklmnop'
CURRENT=$(curl -sS -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/calendars/primary/events/$EVENT_ID?fields=attendees" \
| jq '.attendees // []')
NEW=$(echo "$CURRENT" | jq '. + [{"email":"carol@example.com"}]')
curl -sS -X PATCH -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
-H 'Content-Type: application/json' \
--data "{\"attendees\": $NEW}" \
"https://www.googleapis.com/calendar/v3/calendars/primary/events/$EVENT_ID?sendUpdates=all" \
| jq '{id, attendees}'
Cancel / delete an event
EVENT_ID='abc123def4567890ghijklmnop'
curl -sS -X DELETE -H "Authorization: Bearer $GOOGLE_CALENDAR_TOKEN" \
"https://www.googleapis.com/calendar/v3/calendars/primary/events/$EVENT_ID?sendUpdates=all" \
-o /dev/null -w 'HTTP %{http_code}\n'
204 = success. To cancel one occurrence of a recurring event, fetch
the instance with events.instances first, then DELETE the
specific instance id (it has a longer EVENT_ID_YYYYMMDDTHHMMSSZ
shape).
Common error codes
| HTTP | meaning | what to tell the user |
|---|---|---|
401 UNAUTHENTICATED |
token expired / revoked | "Reconnect the Google Calendar connector on the Connections page." |
403 insufficientPermissions |
write scope missing | "This action needs the Calendar read+write scope, but only calendar.readonly was granted. Re-install the connector with the read+write box checked." |
403 forbidden |
calendar id not visible to this account | check calendarList first; if it's a shared calendar, the owner needs to share it. |
404 notFound |
wrong event / calendar id | double-check the id and try calendarList to confirm the calendar exists. |
409 conflict |
recurring event id collision | append a UUID to your requestId and retry. |
429 quotaExceeded |
quota / throttling | back off ~5s, then retry once. |
Never log or echo $GOOGLE_CALENDAR_TOKEN — treat it as a secret.
More from acedatacloud/skills
luma-video
Generate AI videos with Luma Dream Machine via AceDataCloud API. Use when creating videos from text prompts, generating videos from reference images, extending existing videos, or any video generation task with Luma. Supports text-to-video, image-to-video, and video extension.
10short-url
Create short URLs via AceDataCloud API. Use when generating shortened links for sharing, or batch-creating multiple short URLs at once. Supports custom slugs and expiration.
9seedream-image
Generate and edit AI images with Seedream (ByteDance) via AceDataCloud API. Use when creating images from text prompts, editing existing images, or working with high-resolution outputs. Supports Seedream 3.0 T2I, 4.0, 4.5, 5.0, and SeedEdit 3.0 models.
9flux-image
Generate and edit images with Flux (Black Forest Labs) via AceDataCloud API. Use when creating images from text prompts, editing existing images with text instructions, or when high-quality image generation is needed. Supports multiple Flux models including dev, pro, ultra, and kontext for editing.
9veo-video
Generate AI videos with Google Veo via AceDataCloud API. Use when creating videos from text descriptions, animating still images into video, upscaling/extending videos, re-shooting with new camera motion, or inserting/removing objects. Supports Veo 2, Veo 3, and Veo 3.1 models including fast variants.
9sora-video
Generate AI videos with OpenAI Sora via AceDataCloud API. Use when creating videos from text prompts, generating videos from reference images, or using character references from existing videos. Supports text-to-video, image-to-video, and character-driven generation with multiple models and resolutions.
8