/packs / google-calendar

google-calendar

Calendar itself, in the workspace: events listed, read, created and edited without invitations.

  • integration
  • sources
  • Google Calendar

As of yardr at commit 67389f7, 11 Nov 2026. The pack's own files keep the author's words: issue · bead, repo · depot, workspace · yard, agents · crew, agent · crew member.

01 / apply it

yardr pack apply google-calendar

yardr carries this pack in its binary; the name is enough. yardr pack check google-calendar shows what it would change first. It goes into the workspace itself, and into no repo · depot.

yardr pack apply https://yardr.dev/packs/google-calendar/

From this site, as the files below.

02 / the files

Every file, as it ships.

# Calendar as a pack of kind source: the integration, imported into the
# yard and into no depot. What Calendar is here: the declared source
# google-calendar, which lists and reads events, lists the calendars,
# and creates and edits timed events without inviting anyone. Its
# credential mints from a gws login on this machine, by google-mint, the
# mint command the pack brings beside its declaration: applying the pack
# sets it, with no command typed, and a mint_command the operator sets
# on a source wins over it. One command mints every account, choosing
# the login by the account it is given: the yard's login for the yard's
# calls, that account's own login beside it for a call --as a user. The
# login, the account, the mint and calling as a user are the four google
# packs' shared setup, said once in docs/google.md.
#
#   yardr pack apply google-calendar
#
# A person applies the packs they want, each by one card or typed. A
# pack of kind source asks no grants at import, so what a crew member
# may call is granted afterwards, by the setup notes: read for the
# reads, each update by its name, plain by the operator and as a user
# by that user alone.
name: google-calendar
kind: source
setup: SETUP.md

sources:
  google-calendar:
    declaration: google-calendar.yaml

# Suggested grants: named sets of the source's calls, printed numbered by
# pack show. Suggesting grants nothing: the operator grants them, and
# terminal pack apply ignores them.
presets:
  read:
    about: List and read events and calendars; change nothing
    grants:
      google-calendar: [read]
  create:
    about: List and read events, and create timed ones; no invitations go out
    grants:
      google-calendar: [read, create]
  everything:
    about: List, read, create and edit events; no invitations go out
    grants:
      google-calendar: [read, create, edit]
# Calendar as a declared source: a week's events listed and one read,
# the calendars listed, and an event created and edited without
# inviting anyone, through calendar.events and calendarList. What it
# never does is send an invitation: no attendees on the event it
# creates or edits, and sendUpdates is none. Nor does it move or
# delete an event, share a calendar, or page past the first page
# (pageToken is not declared, so one page is all a call reads). An
# event it creates is timed, never all-day: a date without a time is
# another shape of start and end than this file can say beside
# dateTime.
#
# Checked against the discovery document
# (https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest) on 17
# October 2026: events.list takes timeMin, timeMax, q and maxResults
# (default 250) with singleEvents and orderBy, and answers one page
# with items and nextPageToken; events.get takes the calendar and the
# event and answers the event; calendarList.list answers one page with
# items and nextPageToken; events.insert takes the calendar with
# sendUpdates (all, externalOnly, none) and the event, and answers the
# event it made; events.patch takes the calendar and the event with
# sendUpdates and the fields, and answers the event.
#
# A credential that expires is minted, not kept: GOOGLE_ACCOUNT names
# the account (an address, no secret), and google-mint, the file beside
# this one, turns it into a token from the gws login on this machine:
# run with {inputs, scope} on stdin for a {token, expires_at}. The
# pack brings it (credential.mint_command), so applying the pack sets
# the mint with no command typed; the card shows its sha256, and a
# mint_command the operator sets on google-calendar wins over it. The token
# is handed to each call under GOOGLE_TOKEN, and kept in memory alone.
# Calendar takes no idempotency key, so the file names none: an
# event tried again after a lost answer is made twice.
name: google-calendar
about: Calendar events listed, read, created and edited without inviting anyone; calendars listed.
base: https://www.googleapis.com
credential:
  env: GOOGLE_TOKEN
  header: "Authorization: Bearer {credential}"
  mint:
    - name: GOOGLE_ACCOUNT
      secret: false               # an address, asked in the clear
  mint_command: [google-mint]       # the file beside this one; the source's own mint_command wins
check: calendars
calls:
  events:
    kind: read
    about: List a calendar's events in a window, one page of at most 50
    params:
      calendarId: { type: string, pattern: "^(primary|[^@ ]+@[^@ ]+)$", max: 254, log: true }   # primary, or the calendar's address; given always: a path has no place for a default
      timeMin: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$", max: 64 }   # RFC 3339 with its zone, 2011-06-03T10:00:00-07:00
      timeMax: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$", max: 64 }
      q: { type: string, max: 1000, optional: true }   # free text over summary, description, location and attendees; left out, every event in the window
      maxResults: { type: string, pattern: "^([1-9]|[1-4][0-9]|50)$" }   # 1 to 50, always given: Calendar's own default is 250
    request:
      method: GET
      path: /calendar/v3/calendars/{calendarId}/events
      query: { timeMin: "{timeMin}", timeMax: "{timeMax}", q: "{q}", maxResults: "{maxResults}", singleEvents: "true", orderBy: "startTime", fields: "items(id,summary,start,end,location,attendees(email,responseStatus),htmlLink),nextPageToken" }
    answer: { select: [items, nextPageToken], max: 262144 }   # each event has the fields above; recurring ones are instances; nextPageToken says there is more, which no call reads
  event:
    kind: read
    about: Read one event
    params:
      calendarId: { type: string, pattern: "^(primary|[^@ ]+@[^@ ]+)$", max: 254, log: true }
      eventId: { type: string, pattern: "^[0-9a-v]{5,}$", max: 1024, log: true }   # Calendar event IDs use base32hex characters and are 5 to 1024 characters
    request:
      method: GET
      path: /calendar/v3/calendars/{calendarId}/events/{eventId}
      query: { fields: "id,status,summary,description,location,start,end,attendees(email,responseStatus),htmlLink" }
    answer: { select: [id, status, summary, description, location, start, end, attendees, htmlLink] }
  calendars:
    kind: read
    about: List the calendars, one page
    request:
      method: GET
      path: /calendar/v3/users/me/calendarList
      query: { fields: "items(id,summary,primary,accessRole),nextPageToken" }
    answer: { select: [items, nextPageToken] }   # each with its id, summary, primary and accessRole; nextPageToken says there is more, which no call reads
  create:
    kind: update
    about: Create a timed event, inviting no one
    params:
      calendarId: { type: string, pattern: "^(primary|[^@ ]+@[^@ ]+)$", max: 254, log: true }
      summary: { type: string, max: 500 }   # free text: its value is in no event
      start: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$", max: 64 }   # RFC 3339 with its zone, the event's beginning
      end: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$", max: 64 }   # past start, else Calendar refuses the event
      description: { type: string, max: 10000, optional: true }   # free text, left out, no description
      location: { type: string, max: 500, optional: true }   # free text, left out, nowhere
    request:
      method: POST
      path: /calendar/v3/calendars/{calendarId}/events
      query: { sendUpdates: "none", fields: "id,summary,start,end,htmlLink" }   # no mail about the event: with no attendees there is no one to tell
      body: { summary: "{summary}", start: { dateTime: "{start}" }, end: { dateTime: "{end}" }, description: "{description}", location: "{location}" }   # a description or location left out leaves the key, so the event has none
    answer: { select: [id, summary, start, end, htmlLink] }
  edit:
    kind: update
    about: Change an event, only what is given
    params:
      calendarId: { type: string, pattern: "^(primary|[^@ ]+@[^@ ]+)$", max: 254, log: true }
      eventId: { type: string, pattern: "^[0-9a-v]{5,}$", max: 1024, log: true }
      summary: { type: string, max: 500, optional: true }   # free text; left out, the summary stays
      start: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$", max: 64, optional: true }   # RFC 3339 with its zone; left out, the beginning stays
      end: { type: string, pattern: "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$", max: 64, optional: true }
      description: { type: string, max: 10000, optional: true }
      location: { type: string, max: 500, optional: true }
    request:
      method: PATCH
      path: /calendar/v3/calendars/{calendarId}/events/{eventId}
      query: { sendUpdates: "none", fields: "id,summary,start,end,htmlLink" }   # no mail about the change
      body: { summary: "{summary}", start: { dateTime: "{start}" }, end: { dateTime: "{end}" }, description: "{description}", location: "{location}" }   # a field left out leaves its key, so only what is given changes; a start or end left out leaves an empty start or end, which the patch reads as no change there
    answer: { select: [id, summary, start, end, htmlLink] }
#!/bin/sh
# Mints a Google access token from a gws login: the mint command the
# google packs' declarations bring (credential.mint_command), whose
# credential names GOOGLE_ACCOUNT under credential.mint. Applying a
# pack copies it under <home>/sources/<source>/; the core runs it with
# {inputs, scope} on stdin and reads {token, expires_at} on stdout.
#
# GOOGLE_ACCOUNT is the account's address, no secret. The refresh
# credentials come from the local gws login for that account: the
# default login in ~/.config/gws where it is for that account, else
# that account's own login in ~/.config/gws-accounts/<account>,
# chosen by this script with
# GOOGLE_WORKSPACE_CLI_CONFIG_DIR for the gws it starts alone. gws auth
# status tells whose login one is, and gws auth export --unmasked prints
# the client id, client secret and refresh token, which are exchanged at
# the token endpoint for an access token. No new copy of a secret is
# stored: the three are held in this process's memory alone, sent in the
# exchange's body, and never printed, neither on stdout nor on stderr,
# nor written anywhere.
#
# Needs curl on PATH and gws: GWS_BIN names another gws, else the one on
# PATH, else the first gws in the common install directories of both
# platforms, the operator's own before the machine's.
# GOOGLE_TOKEN_ENDPOINT may name a loopback HTTP endpoint for a test's
# stand-in. The scope is the call's;
# Google's token narrows by the gws grant, not by call, so it is read
# and ignored.
set -eu

fail() { echo "google-mint: $*" >&2; exit 1; }

command -v curl >/dev/null 2>&1 || fail "needs curl on PATH"
if [ -n "${GWS_BIN:-}" ]; then
  case $GWS_BIN in "~/"*) GWS_BIN=$HOME/${GWS_BIN#"~/"} ;; "~") GWS_BIN=$HOME ;; esac
  [ -x "$GWS_BIN" ] || fail "GWS_BIN=$GWS_BIN is no program to run"
  GWS=$GWS_BIN
elif command -v gws >/dev/null 2>&1; then
  GWS=gws
else
  # A process started with a short PATH (the daemon's own) still finds
  # the gws its operator installed: the directories gws's install
  # methods use on either platform, the operator's own before the
  # machine's.
  GWS=
  for dir in "${HOME:-}/.local/bin" "${HOME:-}/.cargo/bin" /opt/homebrew/bin /usr/local/bin; do
    if [ -x "$dir/gws" ]; then
      GWS=$dir/gws
      break
    fi
  done
  [ -n "$GWS" ] || fail "needs gws on PATH or in a common install directory (or GWS_BIN=/path/to/gws): the operator's gws login is what the token is minted from"
fi

input=$(cat)
[ -n "$input" ] || fail "reads {inputs, scope} on stdin, and got nothing"

# first <text> <key> is the value of the first "key": "value" in text, ""
# when there is none. Values with an escaped quote in them are not read:
# an account, a scope, gws's answers and Google's have none. A line gws
# prints outside its answer (Using keyring backend: keyring, on stderr)
# has no quoted pair in it, so it is passed over either way.
first() {
  printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" |
    head -n 1 | sed 's/^[^:]*:[[:space:]]*"//;s/"$//'
}

# num <text> <key> is the number of the first "key": <number> in text, ""
# when there is none.
num() {
  printf '%s' "$1" | grep -o "\"$2\"[[:space:]]*:[[:space:]]*[0-9][0-9]*" |
    head -n 1 | sed -n 's/[^0-9]*\([0-9][0-9]*\)/\1/p'
}

# shell_quote <text> is text as one shell word: as it is when nothing in
# it is special, single-quoted otherwise. For the login command a
# refusal names: the account that names its directory is data, so a
# ';', '$' or space in it must not become a command when it is copied.
shell_quote() {
  case $1 in
    *[!A-Za-z0-9/._+:,@%=-]*|'')
      printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
      ;;
    *)
      printf '%s' "$1"
      ;;
  esac
}

account=$(first "$input" GOOGLE_ACCOUNT)
[ -n "$account" ] || fail "its inputs name no GOOGLE_ACCOUNT"
printf '%s' "$input" | grep -q '"scope"' || fail "reads {inputs, scope} on stdin, and got no scope"
endpoint=${GOOGLE_TOKEN_ENDPOINT:-https://oauth2.googleapis.com/token}
if [ "$endpoint" != "https://oauth2.googleapis.com/token" ]; then
  # The override is for the test stand-in only. Keep refresh credentials
  # from being sent to an endpoint supplied by the process environment.
  case $endpoint in
    http://127.0.0.1:*)
      port=${endpoint#http://127.0.0.1:}
      case $port in ''|*[!0-9]*) fail "GOOGLE_TOKEN_ENDPOINT is only allowed for a loopback test endpoint" ;; esac
      ;;
    *) fail "GOOGLE_TOKEN_ENDPOINT is only allowed for a loopback test endpoint" ;;
  esac
fi

# Whose login gws holds: the exchange below mints for whoever that is,
# so another account than the inputs name is refused, not minted. The
# default login is tried first, exactly as before; only where it answers
# another account is that account's own login tried, in the directory the
# pack declares beside gws's own.
status_out=$("$GWS" auth status 2>&1) || fail "gws auth status failed (exit $?): $(printf '%s' "$status_out" | head -c 500)"
user=$(first "$status_out" user)
[ -n "$user" ] || fail "gws auth status answered with no user"
if [ "$user" = "$account" ]; then
  # The refresh credentials, decrypted by gws: masked without
  # --unmasked, so that flag is the point. What fails is said in one
  # line: the three values reach neither stdout nor stderr from here on.
  export_out=$("$GWS" auth export --unmasked 2>&1) || fail "gws auth export failed (exit $?)"
else
  # The account names a directory below, so one that is no single name
  # is refused before it names one.
  case $account in
    *"/"*|"."|"..") fail "GOOGLE_ACCOUNT=$account names no per-account login (~/.config/gws-accounts/<account>)" ;;
  esac
  [ -n "${HOME:-}" ] || fail "HOME is not set: cannot find the login for GOOGLE_ACCOUNT=$account"
  acct_dir=$HOME/.config/gws-accounts/$account
  login_cmd="GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$(shell_quote "$acct_dir") gws auth login -s gmail,drive,docs,slides,calendar"
  # gws just answered above, so what fails here is the login in that
  # directory, not gws: the refusal names the login that makes it, and
  # not what gws said.
  status_out=$(GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$acct_dir "$GWS" auth status 2>&1) || fail "no gws login for GOOGLE_ACCOUNT=$account in $acct_dir ($login_cmd makes it)"
  user=$(first "$status_out" user)
  [ -n "$user" ] || fail "no gws login for GOOGLE_ACCOUNT=$account in $acct_dir ($login_cmd makes it)"
  [ "$user" = "$account" ] || fail "the gws login in $acct_dir is for $user, not GOOGLE_ACCOUNT=$account ($login_cmd makes it)"
  export_out=$(GOOGLE_WORKSPACE_CLI_CONFIG_DIR=$acct_dir "$GWS" auth export --unmasked 2>&1) || fail "gws auth export for GOOGLE_ACCOUNT=$account in $acct_dir failed (exit $?)"
fi
# (What it said is not repeated: on this line the refresh credentials
# themselves would be the leak.)
client_id=$(first "$export_out" client_id)
client_secret=$(first "$export_out" client_secret)
refresh_token=$(first "$export_out" refresh_token)
[ -n "$client_id" ] && [ -n "$client_secret" ] && [ -n "$refresh_token" ] ||
  fail "gws auth export answered with no refresh credentials"

# The exchange is one form, sent as it is: what gws exports (letters,
# digits, dots, tildes, slashes and hyphens) needs no encoding, and
# anything else is refused rather than sent half-encoded. The body goes
# over the pipe, not in an argument and not in a file.
case $client_id$client_secret$refresh_token in
*[!A-Za-z0-9._~/-]*)
  fail "what gws answered is no refresh credentials" ;;
esac
out=$(printf 'grant_type=refresh_token&client_id=%s&client_secret=%s&refresh_token=%s' \
  "$client_id" "$client_secret" "$refresh_token" |
  curl -s --max-time 10 -w '\n%{http_code}' -d @- "$endpoint") ||
  fail "could not reach $endpoint (curl exit $?)"
code=$(printf '%s' "$out" | tail -n 1)
body=$(printf '%s' "$out" | sed '$d')
# The response body is untrusted and could echo the refresh credentials.
# Report only the status code on failure.
[ "$code" = "200" ] || fail "minting a token was answered $code"
token=$(first "$body" access_token)
expires_in=$(num "$body" expires_in)
[ -n "$token" ] && [ -n "$expires_in" ] || fail "minting a token came back with no token"
# The answer is JSON the core reads: a token outside what Google sends
# is refused rather than printed through it.
case $token in *[!A-Za-z0-9._-]*) fail "the token Google answered is no token" ;; esac
now=$(date +%s)
exp=$(date -u -r "$((now + expires_in))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null ||
  date -u -d "@$((now + expires_in))" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null) ||
  fail "cannot write the time (date reads neither -r nor -d here)"
printf '{"token":"%s","expires_at":"%s"}' "$token" "$exp"
# Setting up Calendar

The pack wires Calendar into the yard as the declared source
`google-calendar`: events listed and read, the calendars listed, timed
events created and edited without inviting anyone. Each call goes out
with a
short-lived token minted from a gws login on this machine, so no new
copy of a secret is stored anywhere. The login, the account, the mint
and calling as a user are the four google packs' shared setup, said
once in `yardr docs google`.

1. **The gws login.** It needs the Calendar scope,
   `https://www.googleapis.com/auth/calendar`, else the calls come
   back 403. The shared login instructions in `yardr docs google`
   start from a machine with no gws, and show how to check and add
   the `calendar` service while keeping the other applied packs'
   scopes.
2. **The setup card.** One card carries the pack's whole setup: the
   pack, the account and the grants. Have a crew member ask for it,
   and approve it, in footplate or in `yardr chat` (`ctrl+o`); a
   person sets up the packs they want, one card each:
   ```sh
   yardr bead ask <bead> "Set up Calendar for the yardmaster?" --setup google-calendar --crew yardmaster --recommend 1 --why "reading is the job; creating can follow" --context "The yardmaster lists and reads calendar events to work beads."
   ```
   The card shows the declaration whole with its mint, the account
   to type or take (step 3), and one option per preset (step 5).
   By hand it is the operator's to type, step by step, starting
   with the pack:
   ```sh
   yardr pack apply google-calendar
   # applied pack google-calendar to the yard: 1 created, 0 changed, 0 unchanged
   # source google-calendar is declared: 5 calls under https://www.googleapis.com (yardr source show google-calendar)
   ```
3. **The account.** The yard's Google account for
   `google-calendar` is answered on the setup card, as described in
   `yardr docs google` (step 3): typed unseen on the first pack, then
   picked.
4. **The mint.** Nothing to type: the pack brings `google-mint`
   beside its declaration, and applying it (step 2) copies it for
   `google-calendar`, its sha256 on the card. What it needs and refuses,
   and how a mint of the operator's own replaces it, are in
   `yardr docs google`.
5. **The grants.** A pack of kind source asks none at import, so what
   a crew member may call is picked on the setup card as one preset.
   The pack's presets, as `yardr pack show google-calendar` prints
   them numbered:
   1. `read`: every read call — events and calendars listed and read —
      and it covers any read call the source is later given; changes
      nothing.
   2. `create`: every read call, and `create`: timed events on the
      yard's calendar; no invitations go out.
   3. `everything`: every read call, `create` and `edit`: events
      created, and edited changing only the fields it names; no
      invitations go out.
   A grantee who comes later gets one preset grant card:
   ```sh
   yardr bead ask <bead> "Let the reviewers read Calendar" --run "source grant google-calendar --preset read --group reviewers" --context "The reviewers list and read calendar events to check a bead's work; the card names each call it covers."
   ```
   By hand they are the operator's to type:
   ```sh
   yardr source grant google-calendar read --crew yardmaster
   yardr source grant google-calendar create --crew yardmaster
   yardr source grant google-calendar edit --crew yardmaster
   ```
   As ada, the same grants with `--as ada`, on cards addressed to
   ada and approved by ada alone: the shared card is in
   `yardr docs google` ("Calling as a user"), one card per grant, with
   `read`, `create` and `edit` from above. There is no typed way beside
   these: a grant `as` ada is ada's to give.
   `yardr source show google-calendar` lists each call with its kind
   and who may make it, plain and `as` each user.
6. **Check it.** The calendar source's check call lists the
   calendars, minted and live:
   ```sh
   yardr source check google-calendar
   # google-calendar: credential: GOOGLE_ACCOUNT is set in /Users/me/.yardr/sources/google.env, minted, good until 16:59
   # google-calendar: check call calendars (GET /calendar/v3/users/me/calendarList): 200 in 412ms, 1834 bytes
   ```
7. **Try it.** As the operator, who may make any call: the calendars,
   this week's events, one event read, an event created:
   ```sh
   yardr call google-calendar calendars
   yardr call google-calendar events calendarId=primary timeMin=2026-10-20T00:00:00Z timeMax=2026-10-27T00:00:00Z maxResults=10
   yardr call google-calendar event calendarId=primary eventId=<an id the listing showed>
   yardr call google-calendar create calendarId=primary summary="Lunch with Ada" start=2026-10-24T12:00:00+02:00 end=2026-10-24T13:00:00+02:00 location="Cafe Roma"
   ```
   An edit changes only what it names; what it leaves out stays:
   ```sh
   yardr call google-calendar edit calendarId=primary eventId=<an id> location="Cafe Novo"
   ```
   Every call names its calendar: `primary` is the account's own.
   Times are RFC 3339 with their zone, and the event created or
   edited invites no one: no invitation goes out of the yard.

Both write calls set `sendUpdates=none`. Calendar documents that as
no notifications and warns it can prevent syncing to external
calendars or lose events for some users. An edit to an event with
existing guests also sends them no change notice. Use these calls
only when that is intended ([events.patch](https://developers.google.com/workspace/calendar/api/v3/reference/events/patch),
[events.insert](https://developers.google.com/workspace/calendar/api/v3/reference/events/insert)).

Calling as a user — the yard reading another person's calendar with
their own credential — is the shared setup too: `yardr docs google`
has it.

What is not there, on purpose: inviting anyone (no attendees, and
sendUpdates is none); moving or deleting an event; all-day events
(a date without a time); sharing a calendar; paging past the first
page; and no call the pack does not list. The yard's source format
cannot express what is not listed, and the pack does not change
yardr.