Magic Apps

Documentation

macmagic.app

Calendar wire format

Google Calendar's web client does not use the public Calendar API JSON names. This page covers the raw payload, what the decoder does with it, and the measured field positions. The poller that fetches it is calendar-sync.md.

What the endpoint returns

The decoder reads POST https://calendar.google.com/calendar/u/<index>/sync.prefetcheventrange. Google's web client does not use Calendar API v3 JSON names (summary, start.dateTime, conferenceData.entryPoints); it serialises internal Protocol Buffers into nested positional JavaScript arrays compiled by Google Closure (jspb), preceded by the anti-XSSI guard )]}'\n\n.

stripXssiGuard removes the prefix and JSON.parse reads the rest. A response that is not the feed is the unauthenticated one, [["er", null, null, null, null, 401, ...]]; isUnauthenticatedResponse checks first[0] === 'er' && first[5] === 401 and parsePrefetchEventRangeResponse throws CalendarSessionExpiredError.

Raw wire response

)]}'

[
  [
    "prefetcheventrangeaction.perr",
    0,
    [
      "<syncTokenBlob>",
      [
        [
          "alex@company.com",
          [
            [
              "0r7ud0l6qsg353f6opf5brh3f4",
              0,
              "https://www.google.com/calendar/event?eid=...",
              1733511428000,
              1739211776052,
              "Team Standup & Planning",
              null,
              null,
              null,
              ["alex@company.com", null, null, true],
              null,
              null,
              ["RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"],
              /* ... remaining fields ... */
              [1789027200000],
              [1789030800000]
            ]
          ]
        ]
      ]
    ]
  ]
]

Decoding

Shape of a clean event

The decoder module apps/magical/src/calendar-decoder.ts strips the XSSI prefix and normalises raw tuples into typed NormalizedCalendarEvent objects:

{
  "id": "0r7ud0l6qsg353f6opf5brh3f4",
  "calendarIds": ["alex@company.com", "team@group.calendar.google.com"],
  "calendarName": "Work Calendar",
  "summary": "Team Standup & Planning",
  "webUrl": "https://www.google.com/calendar/event?eid=...",
  "created": "2026-08-15T10:03:48.000Z",
  "updated": "2026-09-12T14:22:56.052Z",
  "startTime": "2026-09-13T09:00:00.000Z",
  "endTime": "2026-09-13T10:00:00.000Z",
  "isAllDay": false,
  "timezone": "Europe/London",
  "organizerEmail": "alex@company.com",
  "recurrenceRule": "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
}

What decoding guarantees

Decoding a dense positional array needs no protobuf library or schema compiler: a mapping function reads the fixed positions in one pass, which keeps the rest of Magical clear of the wire's quirks.

The stability argument covers one of the three ways this payload can change:

  1. Field additions and deprecations are safe. jspb indexes by Protocol Buffer field number, so a new field appends to the array without moving the fields already there, and a deprecated field leaves a null hole rather than compacting. Tuple length drifts as a result, so nothing may key off it beyond a floor check.
  2. Envelope restructuring is not safe. A renamed action token, a different nesting of the blocks around the events, or a move to a different internal service breaks parsing before any field is reached. A Calendar web client rewrite looks like this.
  3. New required request parameters are not safe. Google web RPCs commonly grow an at= XSRF token, a bl= client build label or a session id as anti-abuse hardening. calendar-sync.md's Zero-OAuth session mechanism measured none as required today, a snapshot rather than a contract. Recovery is to read the token out of the live calendar page in the account's WebContentsView.

Pinned indices

The decoder reads each field at the position its field number gives it, validates the type and range of what it finds, and throws when the value is not there. The positions live in calendar-decoder.ts as IDX, ATTENDEE and EVENT_TYPE, beside the code that reads them; a table repeating them here would drift out of step silently.

Protocol Buffer field numbers are assigned by a schema author once and never renumbered, because renumbering breaks Google's own rolling deploys. A field addition appends and a deprecation leaves a hole, so existing fields do not move. Scanning for a field by shape defends against a case that does not happen, since no amount of scanning inside a tuple helps with envelope restructuring or a new request parameter, and it turns a loud failure into a quiet wrong one: an adjacent created/updated pair, or a reminder time, read as the event start puts a banner at the wrong hour. A pinned field that no longer matches is a one-line fix found by a failing canary.

A decode that fails throws CalendarDecodeError. The throw reaches the staleness watchdog, which tells the user Magical cannot read their calendar; a guess reaches them as a wrong alert.

Features the decoder resolves

Event title and times

IDX.summary (5) carries the title, and parseEventTuple falls back to (No title). IDX.start (35) and IDX.end (36) carry the two timestamps, read by parseTimestampTuple. IDX.created (3) and IDX.updated (4) carry two epoch numbers, null when absent rather than defaulted to now, which would read as a fresh event.

All-day is the shape of the start, not a flag. A timed event carries [null, [epochMs], "Europe/London"]; an all-day entry carries a bare [epochMs] with no timezone, so isAllDay reads the absence of the timezone. It mirrors the public API, where an all-day event has start.date and a timed one has start.dateTime. There is no boolean all-day flag anywhere in the payload.

isValidCalendarDate bounds a decoded date to 1900 to 2100. The window is wide because a calendar legitimately holds dates far outside a working year: a birthday from Contacts carries the person's year of birth, and one born before the lower bound would throw and take its whole account's payload with it.

Pending invitations

An event is awaiting a reply while the account holder's response code is 0. Each event carries its guest list at IDX.attendees (20), and each guest carries their response at ATTENDEE.responseStatus (5). The response codes, measured against a live account:

Code Meaning
0 No response
1 Declined
2 Tentative
3 Accepted

One guest carries ATTENDEE.ownsThisCalendar (9), but that flag names the owner of the calendar this copy arrived under, not the signed-in user; on a colleague's calendar it flags the colleague. parseEventTuple finds the account holder by address instead, and their response is the same in every copy. Without an address to look for, the flag is the best available guess and is right whenever the block is the account's own calendar.

"Not invited" is a third state, distinct from "invited and unanswered". An event visible on a shared calendar that the user is not a guest of is not awaiting their reply, and badging it as such would light the Dock up permanently. myResponseStatus reads 'notInvited' for it.

invite-watcher.ts spots the ones that are pending:

export function isPendingInvite(event: NormalizedCalendarEvent): boolean {
  return event.myResponseStatus === RESPONSE_NEEDS_ACTION;
}

inviteKey keys the series rather than the occurrence, so one invitation to a recurring meeting is announced once. A notification banner opens the event rather than answering it; neither of Google's two answer routes is usable, and answering drives Google's own controls instead. actions.md's Answering an invitation owns the mechanism.

Conference links

resolveConferenceUrl reads three sources in order:

  1. The structured conference block, at IDX.conference (57). Its first element is a list of entry points, each [typeCode, uri, label, ...], where typeCode 3 is the video entry and 4 and 5 are dial-in numbers; its last element is the conference id. Decode this one, because it covers third-party conferencing added through Google's conferencing API rather than only Meet.
  2. The bare Meet URL, at IDX.meetUrl (45), the legacy hangoutLink equivalent. Cheap to read, but Google Meet only.
  3. The free text, IDX.description (64) first and IDX.location (7) second, scanned by findJoinUrlInText.

Read the conference block for the video entry point and fall back to the Meet URL. The committed fixture has an empty conference block because sanitisation stripped it, not because the field was absent. Conferencing that was never registered with Google leaves all three empty; that is the common Teams case, where the invitation body carries the join link. conference-links.md owns how that body is scanned and how the URL is resolved and launched.

Working locations

IDX.eventType (82) carries 10 on a recurring working location and 11 on one set for a single day. Measured against a live account, "Home", "Office" and a named office all arrive as 10 when they belong to a series and as 11 when the user sets one for a single day. Both draw the same way.

Which location it is comes from the title, because nothing else carries it. Google's own Calendar API has a workingLocationProperties.type, and it reports HOME_OFFICE for a home day, for one titled "Office" and for a named office alike. Google generates all three titles it owns, being "Home", "Office" and " (Office)" for one of the organisation's offices; anything else is a label the user typed, and takes the pin. workingLocationGlyph in WidgetCache.swift maps the title to house, building.2 or mappin.and.ellipse. This heuristic is safe here because it runs only once the wire has said the entry is a working location, and a wrong guess costs one glyph rather than arming an alert at the wrong hour.

A day has a regular working location, and the user overrides it a day at a time. The override does not cancel the regular entry: the series carries no EXDATE, the occurrence is not tombstoned, and Google's own API reports both as confirmed. Google draws only the override, which is why its client shows one where a faithful decode shows two. collapseWorkingLocations in working-locations.ts does the same, keeping the override over the series occurrence and the later of two overrides. It runs over the merged cache rather than the fetched list, because the entry an override replaces may have arrived in an earlier poll. This covers the all-day entry, the one that states where the day is spent; a working location given a time is an appointment like any other, and two ordinary all-day events on a day both stay.

Out of office

IDX.eventType (82) carries 3. The decoder reads it into eventType; nothing draws it, and alert-scheduler.ts excludes all-day entries, so an out-of-office block arms no alert. It sits on the account's own calendar and reads as an ordinary appointment by every other measure.

Focus time

IDX.eventType (82) carries 7. Focus Time entries draw with a headphones glyph before the title, from kindGlyph in TrayModel.swift and WidgetCache.swift.

Reminders

IDX.reminders (33) carries the event's own notification times, present only when it overrides its calendar's. A list of [method, minutes], as [[2,15],[2,5]] for two notifications or [[0,10]] for an email, where REMINDER_NOTIFICATION is 2 and REMINDER_EMAIL is 0. parseEventTuple keeps the notification entries only, since an email reminder is not one this app can deliver and the alert engine would treat its minutes as a banner time. alert-scheduler.ts takes the longest of them with Math.max, which is the earliest banner.

An event that takes its calendar's default carries nothing here, and so does one whose notifications the user has removed; the two cannot be told apart from this payload, and the calendar's own default is not in the payload at all. Both fall back to the global lead.

Colour

An event names a colour label rather than a colour. IDX.eventLabel (91) carries the uuid of the label the event overrides its calendar with, present only when it overrides. readLabelColours reads the account's whole label set from payloadBlock[2], [null, "<labelId>", <colourId>, ...] per calendar, and maps each label to a hex through colourForId; parseEventTuple resolves the event's label to colorHex.

The wire carries a colour id and not a hex, and colourForId resolves it against the checked-in palette: google-colours.json holds the 24 hexes in file order, the position being the id. Peacock, id 14, is #039BE5, the colour Google paints an account's own calendar. The palette is checked in rather than read at runtime because it is static; Google has remapped it once, and if it happens again these hexes are what goes stale, visibly.

Event kind

IDX.eventType (82) is the only thing that separates entries every other field reads alike. Tasks and contact birthdays have no block of their own: asking for tasks@tasks.google.com or birthdays@birthdays.google.com returns an empty payload even when it is the only calendar named, so their entries arrive inside the primary calendar's block and nothing keyed on a calendar id separates them from an ordinary appointment. A title heuristic is not a substitute: an ordinary event with "birthday" in its name is DEFAULT.

eventType Entry Glyph
1 From Gmail none
3 Out of office none
7 Focus time headphones
9 Task circle, or checkmark.circle once complete
10 11 Working location house, building.2 or mappin.and.ellipse
13 Birthday birthday.cake

An entry has one eventType, so it gets one glyph: kindGlyph in TrayModel.swift and WidgetCache.swift maps it. A contact birthday carries the contact's id at IDX.contact (83). FOLDED_INTO_PRIMARY re-files a birthday onto birthdays@birthdays.google.com and a task onto tasks@tasks.google.com, so the entry takes that calendar's colour and name and leaves the screen when the user hides it.

Recurring series

The payload carries a recurring meeting in two forms at once, and both decode into ordinary events.

Double arrival

The series master holds the RRULE at IDX.recurrence (12) and starts on the series' first date, which for a birthday is 1958. Alongside it comes one event per occurrence Google has already materialised, its id stamping the series and the start the rule gave that occurrence in UTC, <seriesId>_20260918T084000Z, and its seriesId at IDX.seriesId (13) naming the master.

expandRecurringEvents in calendar-recurrence.ts generates only the occurrences the wire left out. It records which slot each materialised event takes, keyed by series and day, and generates nothing in a slot already taken. A series runs at most once a day at every frequency Google's own editor can produce, so the day identifies the occurrence.

Three things make this easy to get wrong:

Where both exist, the wire's copy is the one kept: it carries whatever was edited on that occurrence alone, such as a room the rest of the series does not use. IDX.seriesAnchor (55) is the series' recurrence anchor, identical on every occurrence; it reads like an instance id and is not one. IDX.uid (18) carries the iCalendar UID.

Recurrence rule

Everything RFC 5545 says about where an occurrence falls inside its period belongs to the rrule package: the ordinal on BYDAY=4WE and BYDAY=-1FR, BYSETPOS, BYMONTHDAY including negative and repeated values, BYMONTH, and WKST. COUNT and UNTIL are its job too, and where a rule carries both the earlier of the two ends the series.

Two things wrap the library:

  1. A daily or weekly anchor moves up to just before the window. Walking from an anchor in 2015 takes about 500 ms a poll on a hundred-master calendar; skipping whole periods drops that to 20 ms without shifting the phase. Monthly rules are left alone because month lengths vary, and rules with a COUNT are left alone because the count runs from the original anchor.
  2. A series contributes at most MAX_OCCURRENCES (1,000) occurrences to one window. Google's editor cannot create sub-daily rules, but imported .ics files can, and an hourly series over a month would otherwise generate 721 chips. An occurrence Google has already materialised still takes its place in the count, because the limit comes from the rule rather than from what the expansion generated.

A rule the library cannot read leaves the master in the agenda on its own date and generates nothing. That is the quiet failure: one event where a series was meant to be, rather than a series in the wrong place.

Range payload

Where each field sits is IDX, ATTENDEE and EVENT_TYPE in calendar-decoder.ts; those positions belong beside the code that reads them. What is worth saying here is the shape, which no list of fields conveys.

An event comes back once per calendar that holds it, and Magical records every one. parsePrefetchEventRangeResponse merges the copies by event id into a single event carrying them all in calendarIds, rather than picking a copy. Anything needing one calendar, for a colour or a name, takes the first the user has not hidden; a filter drops the event only when it hides all of them. Two objects for one meeting would arm its alert twice and count it twice in the Dock badge.

IDX.calendar (34) names the calendar holding the event's canonical copy, which for an invitation is the organiser's. The calendar a copy sits on is the block it came back under. Read as "this event's calendar", IDX.calendar gives every invitation a calendar the user does not have, so nothing keyed by calendar id matches: not the colour, not the name, not the popover's hidden-calendar filter.

IDX.webUrl (2) carries the link whose eid names a calendar; the merge keeps the account's own copy so the link can find the chip Google draws for the user. IDX.syncVersion (39) is present where the payload carries one.

A calendar block echoes the range that was asked for and carries nothing else about the calendar: no name, no colour. A block is [id, [event, ...], [null, null, startDay, endDay], n].

The committed fixture is sanitised, and sanitisation moved a field it did not move consistently. Its contact birthday carries a summary replaced with a meeting's, while the fields that identify it as a birthday were left untouched. The fixture is adequate for decoder unit tests and misleading for field discovery; measure against a live account.

Prefetch and fetch

The live client calls both sync.prefetcheventrange and sync.fetcheventrange, and they are not different schemas: the same fields are populated in either, descriptions and conference blocks included. They differ only in how the client uses them: prefetch calls name more calendars over a wider range, fetch calls fewer over a narrower one. That is a caller's choice rather than a property of the endpoint, and either serves the poller. prefetcheventrange remains the documented primary because it is already the subject of calendar-sync.md's Zero-OAuth session mechanism and the canary.

Capture protocol

Everything on this page was read from a live signed-in account inside Magical's own WebContentsView over CDP, by recording Google Calendar's own requests rather than crafting any.

Run against the account's own WebContentsView in a running build, not a browser. A browser console proves the endpoint answers; only the in-app path proves Magical's partition, with its Chrome identity headers, is served the same payload.

Record the real client's traffic rather than constructing a request: it removes any guesswork about the f.req body and the required headers.

Captures contain real event titles, attendee addresses and meeting URLs. Keep them out of the repository, and hand-redact before any of it becomes a fixture.