Inspiration
Every calendar app offers "this and following" when you change a repeating event. What it actually does is end the old series and create a new one — and everything you had customised about future occurrences goes with it.
This is not our claim. It is how the platforms describe their own behaviour. Google's Calendar API guide implements the change as two requests and states that instances after the target are reset. Microsoft's MS-OXOCAL specification says that when a recurrence pattern changes, future exceptions are cancelled and their exception objects removed. Microsoft's own admin guidance warns organisations about exactly this loss.
So when a teacher moves a Tuesday class to Thursday from September, the two public holidays they had cancelled quietly become classes again, the make-up session already moved to a Wednesday disappears, and the guest lecturer's room booking goes with it. Nobody is told. There is no diff, and no undo that brings the exceptions back.
What it does
You give SeriesSafe an .ics export and an ordinary sentence:
"From September, move my Tuesday class to Thursday — but keep the holidays I cancelled, the make-up I already moved, and the guest-lecture room."
It reads the series graph behind the calendar grid, re-anchors every future exception onto the new pattern, shows the same request applied two ways — SeriesSafe's way and the conventional way, with the losses itemised — verifies nine invariants against the serialized .ics bytes, and only then makes committing possible.
Everything runs in the tab. No calendar account, no upload, no server.
How WebMCP is used
A calendar UI renders occurrences. The structure that defines them — which Tuesday is a cancellation rather than a gap, which event is a detached override rather than a separate meeting, which ordinal anchors a given exception — is never in the DOM. An agent working from pixels cannot recover it, and cannot tell a silently-lost exception from one that was never there.
SeriesSafe registers eleven tools on document.modelContext. Nine are always present; two appear only when they are safe:
load_calendar → list_recurring_series → inspect_series → list_series_exceptions → simulate_series_split → stage_series_split → validate_staged_split → commit_staged_split → undo_series_split, plus compare_with_conventional_edit and export_calendar_ics.
Two design decisions carry the weight:
There is no fix_my_calendar tool. The agent must read the structure, propose a change, look at what the dry run says it would cost, then stage, validate and commit. It has to change course on intermediate results — an unsupported rule part, an override that cannot be re-anchored, an end date that would silently cost a meeting.
Dynamic registration is the safety boundary, not a UI state. commit_staged_split is not disabled before validation; it does not exist. Calling it returns "No tool named commit_staged_split is registered right now." After a commit it is withdrawn again and undo_series_split takes its place. This is covered by an automated test.
How we built it
TypeScript and Vite, no runtime dependencies. An RFC 5545 parser that retains every property it does not understand, so "nothing was lost" is provable rather than aspirational. Recurrence expansion walks the wall clock in the event's own zone and converts each occurrence to UTC separately, so "every Tuesday at 19:00" stays at 19:00 across a DST transition.
The core idea is week-anchored alignment: an exception moves to the corresponding slot in the same recurrence week. A cancellation on a Tuesday becomes a cancellation on that week's Thursday, because "that week is off" is indexed by week, not by calendar date. When a week cannot be matched one-to-one, SeriesSafe refuses the change rather than guessing. A detached override is re-anchored the same way but keeps its own start time — a make-up already moved to Wednesday 16 September stays there; only the slot it hangs from moves. That single distinction is what stops it from being either absorbed into the new pattern or duplicated beside it.
Challenges
An RDATE nearly corrupted every later exception. Explicitly added dates are not part of the recurrence pattern, so counting them in the ordinal shifted every subsequent exception by one week — cancelling the wrong class. Fixed by aligning on pattern slots only.
Moving the weekday silently dropped the final meeting. Inheriting a fixed UNTIL while moving Tuesday → Thursday ends the series a week early, because the last Thursday falls before the last Tuesday. Our own validator caught it: "25 kept + 16 moved = 41; originally 42." Rather than pick a behaviour silently, SeriesSafe now exposes the trade-off — preserve-count (default) keeps every meeting and lets the end date shift; keep-end-date is refused when it would cost a class. Both are regression-tested.
Put through adversarial review rounds, and every finding reproduced
The engine was handed to an independent reviewer with one instruction: find defects, and treat "nothing found" as a failed review. Fourteen rounds followed, each attacking the fixes from the last. Every finding was reproduced by execution here before a line was changed, and each is now a regression test.
The first round returned six blocking findings.
The common thread was that the headline claim — we prove nothing was lost — was stronger than what the validator actually checked:
Across the rounds, the recurring shape was that a check measured something adjacent to what it claimed:
- A three-year modelling window rewrote a five-year weekly series as a finite run less than two-thirds its length, dropping a cancellation four years out. All eight invariants passed.
- A UTC series was re-emitted without its
Z, becoming floating local time and moving the event for every viewer outside the writer's zone. All eight passed. - "Verified byte-for-byte" compared
NAME=valueand an alarm count, so a reminder retimed from 30 minutes to 5, or an attendee stripped of name, role and status, read as intact. All eight passed. - Carrying exceptions by flat position put a Thursday cancellation on the following Monday when the boundary week held a different number of meetings under each rule. All eight passed.
FREQ=MONTHLY;BYDAY=1MOwas expanded as the first of the month rather than the first Monday — diverging from ical.js — and the edit was approved.- An
RDATEthat coincided with a rule slot was treated as an addition, pulling a real occurrence out of the pattern.
Alignment is now anchored to the week rather than a position; expansion follows the rule's own COUNT or UNTIL; value types are preserved; the fingerprint is a canonical deep comparison including parameters and nested components; and anything the engine cannot expand exactly is refused instead of approximated. The supported surface got narrower and honest: FREQ=MONTHLY;BYDAY=1MO means the first Monday, and an engine that renders it as the first of the month is not entitled to edit it.
The second round confirmed seven of those closed — and found that some of the fixes had introduced their own problems. A date boundary derived from one offset lookup landed an hour early across a DST change. The engine had started writing a rule it could not itself expand. And the fix for open-ended series was too strict in the other direction, rejecting 199 of 486 perfectly ordinary moves; it is now an equal-length prefix comparison, and a 405-case sweep across five zones, three week-starts and three intervals passes clean.
Rounds three through six continued the same way, each attacking the last round's fixes. The blocking count fell 6, 4, 2, 1, 2, 1 — and the fixes themselves kept needing fixing: the guard against impossible dates began rejecting two legal ones, a leap second and any four-digit year below 0100, which JavaScript maps into the 1900s.
Three rounds in a row then found the same shape of bug — something riding on an EXDATE or RDATE going missing — and the reason each one reached the file was that the date lists were never compared at all. Fixing the third instance was not the answer; comparing the lists was.
Rounds nine and ten were asked a different question — not "can the output be tampered with undetected" but "does SeriesSafe ever write a wrong calendar from ordinary input" — and that turned out to be the more productive framing. It found seven real defects, two of them in the headline demo. The sample's guest lecture, which changed room but was never rescheduled, was being left on the old Tuesday while its anchor moved to the new Thursday.
The most instructive finding came last: three defects lived in code paths the README had already declared out of scope. Monthly and yearly rules were documented as unsupported and expanded anyway, incorrectly. Declaring a boundary is not the same as enforcing it, and the gap between the two is exactly where the bugs were living. The scope is now enforced in code.
That pattern — each round of fixes introducing something new, at a decreasing rate, until a repeat exposes the real gap — is the argument for running more than one round, and for asking what a class of finding has in common rather than closing them one at a time.
Verified against real Chrome, not just a mock
npm run test:webmcp launches Chrome with --enable-features=WebMCPTesting, opens the deployed page and drives the tools over the DevTools Protocol. 34/34 checks pass on Chrome 151.0.7922.175, confirming the page binds to the browser's own ModelContext, that Chrome reports all nine tools over the CDP WebMCP.toolsAdded event, and that commit_staged_split is genuinely absent from getTools() until validation passes.
That run found two bugs nothing else would have:
- The real
executeTooltakes aRegisteredToolfromgetTools()plus arguments as a JSON string; passing a name and an object throws "The provided value is not of type 'RegisteredTool'". Our scripted walkthrough was doing precisely that, and our own stand-in had been permissive enough to hide it. It is now strict in exactly the ways Chrome is. - Withdrawing a tool from inside its own execution aborts that execution —
commit_staged_splitderegisters itself, and up to Chrome 152 that cancels the in-flight call, which fails with "The operation failed for an unknown transient reason" despite the work having completed. Withdrawal is now deferred by one task.
Accomplishments
The validator is not decorative — it caught two real bugs during development, and it checks the serialized file rather than the in-memory plan, so a bug in the writer cannot pass unnoticed. The test suite verifies output with Mozilla's ical.js, an independent parser, including exception relation and occurrence resolution.
Eighty-seven tests, a 405-case sweep over open-ended series across five time zones, thirty-four end-to-end checks against a real Chrome WebMCP implementation, and automated contrast and breakpoint audits that measure the rendered pixel rather than the declared token — all green, all reproducible from the repo with five npm scripts.
What it does not claim
Fourteen review rounds is enough to know the shape of what is left, and not enough to claim there is nothing. The final round found no case where SeriesSafe writes a wrong calendar from ordinary input — that was the question it was asked. What it did find were four rare RDATE combinations that are refused where a more capable tool would accept them. Those decline rather than damage, which is the behaviour this is built around, but they are limitations and the README says so rather than claiming full iCalendar coverage.
Weekly recurrence, verified thoroughly. Everything else, refused rather than guessed.
What we learned
The most useful thing a page can give an agent is not an action — it is the structure behind the rendering, plus an honest refusal when the action cannot be proven safe.
And a validator only proves what it actually compares.
Three things generalised beyond this project. Changing the question from "can the output be tampered with" to "does the tool ever write a wrong file" found defects the first framing had missed for eight rounds. A repeated shape of finding is a signal to fix the class, not the third instance. And a boundary the documentation declares but the code never enforces is where defects live, because nobody tests what the docs say isn't there. Every one of the six blocking findings was a case where the checks were green and the data was wrong, because the check measured something adjacent to the claim: instants instead of content, a property name instead of its parameters, an alarm count instead of the alarm. Writing the invariant is the easy half; making it fail is the half that matters.
What's next
Live OAuth write-back to Google and Microsoft; RANGE=THISANDFUTURE expansion; positional BY* rules; multi-series batch surgery for schools and clinics changing a whole term at once.
Built with
typescript · vite · webmcp · document.modelContext · rfc-5545 · icalendar · ical.js · github-actions · github-pages
Built With
- chrome-devtools-protocol
- github
- typescript
- vite
- webmcp
Log in or sign up for Devpost to join the conversation.