Booking a new meeting is the easy half of a calendar agent. The harder half is changing meetings that already exist: "push tomorrow's review by an hour", "move my weekly 1:1 to Thursdays from next week", "add Priya to the planning meeting", "cancel the stand-up on the holiday". Each of these must find the right event among recurring series and their exceptions, decide whether the change applies to one occurrence or many, avoid overwriting someone else's concurrent edit, and decide who gets notified.

This article builds the change side of a Google Calendar tool for an ADK Java agent, at the REST level so the mechanics are visible. It covers recurring-event identity, a tool surface that keeps raw IDs away from the model, conditional writes with ETags, the array-overwrite trap in patch requests, splitting a series, and notification choices. Free/busy search, first-time booking, scopes and time-zone arithmetic are covered in the companion article linked at the end.

Why changing an event is harder than booking one

A booking creates something new, so the main risks are duplicates and wrong times. A change acts on something shared. The event may have attendees who already accepted, other people with edit rights, a recurrence rule generating dozens of occurrences, and exceptions where single occurrences were already moved. Four questions decide whether a change is correct:

  1. Identity. Which event, and if it recurs, the series or one instance?
  2. Scope. This occurrence, every occurrence, or this one and all after it?
  3. Concurrency. Is the event still what the user saw when they agreed?
  4. Consequence. Who is notified, and is anything else lost?

A language model is good at turning "my Tuesday 1:1" into a search and bad at all four of these as free-form API calls. So the tools carry the rules, and the model chooses among safe operations.

Identity in a recurring world

In the Google Calendar API, a recurring event is a single event resource with a recurrence field: a list of RRULE, EXRULE, RDATE and EXDATE lines. Its occurrences, called instances, are listed with events.instances, or expanded inline by calling events.list with singleEvents=true. Each instance carries two immutable fields that matter here: recurringEventId, the ID of its series, and originalStartTime, the start time the recurrence rule gave it. The second identifies the instance within the series even after someone has moved it to a different time.

Modifying an instance (updating it, or setting its status to cancelled) creates an exception: the series keeps its rule and that one occurrence differs. The documentation warns against changing every instance individually when you mean to change the series, because you end up with a series made entirely of exceptions. Note also that for recurring events start.timeZone is required: it is the zone in which the rule is expanded, which is why a weekly 10:00 meeting stays at 10:00 local time across a clock change.

So the agent's search must expand instances. Searching series without expansion finds a meeting created two years ago with no sign of next Tuesday's occurrence; searching with singleEvents=true and a bounded timeMin/timeMax finds exactly the occurrences the user is talking about, each pointing back to its series.

A tool surface built on handles and proposals

Changing an event: resolve, propose, confirm, write conditionallyUser request"move my 1:1s"find_eventssingleEvents=trueHandle tableid, etag, serieshandlespropose_changescope: one/series/followingConfirmationdiff + notificationsConditional writeIf-Match: etagconfirmed200 OKnew etag stored412 Preconditionre-read, re-validatechanged? re-proposeCalendar APIevents.*The model works with handles and proposals; only confirmed, etag-guarded writes reach the API.
The model sees handles and proposals; confirmed changes are written with If-Match, and a 412 sends the change back through re-validation.

Expose three tools and keep event IDs and ETags out of the model's hands. The search tool returns short handles; the handle table in session state maps each handle to the event ID, series ID, original start and the ETag seen at read time. The model can only refer to events it was shown, and cannot invent or mistype an ID.

public final class CalendarChangeTools {

  @Schema(description = "Find events in a time window. Returns handles like e1, e2 with title, "
      + "start, end, attendees and whether the event is part of a recurring series.")
  public Map<String, Object> findEvents(
      @Schema(name = "from", description = "ISO-8601 start of window, with offset") String from,
      @Schema(name = "to", description = "ISO-8601 end of window, with offset") String to,
      @Schema(name = "query", description = "Words in the title", optional = true) String query,
      ToolContext ctx) {
    List<CalEvent> found = api.list(calendarId, from, to, query, /* singleEvents */ true);
    return Map.of("events", handles.register(ctx.state(), found));   // stores id + etag per handle
  }

  @Schema(description = "Propose moving an event. scope is ONE, SERIES or FOLLOWING. "
      + "Nothing changes until the user confirms.")
  public Map<String, Object> rescheduleEvent(
      @Schema(name = "handle") String handle,
      @Schema(name = "newStart", description = "ISO-8601 with offset") String newStart,
      @Schema(name = "scope") String scope,
      @Schema(name = "notify", description = "ALL, EXTERNAL_ONLY or NONE") String notify,
      ToolContext ctx) {
    Handle h = handles.get(ctx.state(), handle);
    Change change = planner.plan(h, Instant.parse(newStart), Scope.valueOf(scope));
    Optional<ToolConfirmation> conf = ctx.toolConfirmation();
    if (conf.isEmpty()) {
      ctx.requestConfirmation(change.describe() + notifyText(notify), change.toPayload());
      return Map.of("status", "awaiting_confirmation", "summary", change.describe());
    }
    if (!conf.get().confirmed()) return Map.of("status", "declined");
    return writer.apply(change, Notify.valueOf(notify));              // conditional writes
  }
}

Register the methods with FunctionTool.create(tools, method) as with any ADK Java function tool. The confirmation flow is the one ADK Java provides through ToolContext.requestConfirmation(hint, payload): the first invocation asks, the re-invocation after the user answers carries a ToolConfirmation whose confirmed() decides. The summary shown to the user must state the scope in words ("every Tuesday 1:1 from 13 October onward, 11 occurrences"), not the enum.

Concurrent edits: ETags and If-Match

Between the moment the agent read the event and the moment the user says yes, a colleague may have moved it, or the organiser may have changed the attendee list. Every Calendar resource has an etag that changes whenever the resource changes. Send it in an If-Match header on update, patch or delete, and the API applies the change only if the resource is unchanged; otherwise it returns 412 Precondition Failed. Conditional writes are not supported for inserts; for those, a client-supplied ID is what prevents duplicates.

HttpResponse<String> patchIfUnchanged(Handle h, String jsonBody, Notify notify) {
  HttpRequest req = HttpRequest.newBuilder(URI.create(
          BASE + "/calendars/" + enc(calendarId) + "/events/" + enc(h.eventId())
          + "?sendUpdates=" + notify.apiValue()))          // all | externalOnly | none
      .header("Authorization", "Bearer " + tokens.get())
      .header("Content-Type", "application/json")
      .header("If-Match", h.etag())
      .method("PATCH", HttpRequest.BodyPublishers.ofString(jsonBody))
      .build();
  HttpResponse<String> res = http.send(req, BodyHandlers.ofString());
  if (res.statusCode() == 412) throw new StaleEvent(h);   // never retry blindly
  return res;
}

The important decision is what to do with a 412. Do not refetch and retry automatically: the user agreed to a change against a version of the event that no longer exists. Re-read the event, re-run the plan, and compare. If the plan is unchanged in everything the user saw (same times, same attendees, same scope), apply it with the new ETag. If anything they saw is different, return to the model with the new state and ask again.

The patch trap: arrays are replaced

The patch method is attractive for agents because it sends only the fields that change. But the reference is explicit: array fields, if specified, overwrite the existing arrays. attendees is an array. An agent that implements "add Priya" as a patch with {"attendees": [{"email": "priya@example.com"}]} removes every other guest, and with sendUpdates=all tells them so.

Model the edit as a set operation in your code, not in the model's arguments: read the event, take the current attendee list, add or remove the requested people, and patch the complete list with the ETag you read. The tool's parameter is the person to add; the full list is never something the model writes.

Map<String, Object> addAttendee(Handle h, String email, Notify notify) {
  CalEvent ev = api.get(calendarId, h.eventId());                  // fresh copy + etag
  List<Map<String, Object>> guests = new ArrayList<>(ev.attendees());
  if (guests.stream().noneMatch(g -> email.equalsIgnoreCase((String) g.get("email")))) {
    guests.add(Map.of("email", email));
  }
  String body = json.write(Map.of("attendees", guests));            // complete list
  return result(patchIfUnchanged(h.withEtag(ev.etag()), body, notify));
}

The same applies to recurrence, which is also an array, and to reminder overrides. Any field the tool rewrites must be rewritten from a fresh read.

This and following: splitting a series

"Move my 1:1s to Thursdays from next week" changes this occurrence and all following ones. The recurring-events guide documents changing one instance and changing the whole series; it does not document a single operation for "this and following". The usual client-side pattern is a split:

  1. Read the series and its recurrence lines, with its ETag.
  2. Patch the original series so its RRULE ends before the split occurrence by adding an UNTIL. RFC 5545 requires UNTIL in UTC when the start has a time zone, so compute it from the last kept occurrence in the series' zone, then convert.
  3. Insert a new series from the new first occurrence with the new rule, the same attendees and a client-supplied ID, so a retry cannot create it twice.
  4. Re-apply or report exceptions: occurrences after the split that were individually moved or cancelled belonged to the old series and do not carry over by themselves.

Step 2 and step 3 are two requests, so the operation is not atomic. Order them so a failure leaves the safer state: insert the new series first (deterministic ID, safe to retry), then truncate the old one with If-Match. If the truncate fails with 412, delete the new series and re-plan. Record both IDs in session state so a crashed run can be resumed.

Notifications and cancellations

The sendUpdates parameter takes all, externalOnly or none. It is a decision with consequences for other people, so make it a visible part of the proposal rather than a hidden default. A sensible policy: all for time changes and cancellations of meetings with guests, none for edits to the user's own private events, and an explicit question when the user only changes the description of a large meeting. Always send the parameter explicitly instead of relying on whatever the API does when it is omitted.

Cancellations get the same structure: cancelling one occurrence sets that instance's status to cancelled; deleting the series removes every occurrence for every attendee. The confirmation text must say which, and how many future occurrences are affected.

Worked example: moving a weekly 1:1

A user in New York has a weekly 1:1, Tuesdays 10:00-10:30, recurring since March. Two weeks ago they moved one occurrence (27 October) to Wednesday. They ask: "From next week, move my 1:1 with Sam to Thursdays at 2pm."

findEvents for the next three weeks returns e1 (Tuesday 13 October 10:00, part of a series), e2 (Tuesday 20 October 10:00, same series) and e3 (Wednesday 28 October 10:00, an exception of the same series, with originalStartTime Tuesday 27 October). The model calls rescheduleEvent with e1, Thursday 15 October 14:00, scope FOLLOWING, notify ALL. The planner computes: old series ends with Tuesday 6 October, so UNTIL is that occurrence's start, 10:00 EDT, which is 20261006T140000Z; new series RRULE:FREQ=WEEKLY;BYDAY=TH from 15 October 14:00 America/New_York; one exception (28 October) after the split will not carry over.

The confirmation reads: "Move your weekly 1:1 with Sam from Tuesdays 10:00 to Thursdays 14:00, starting 15 October. Sam will be notified. Your one-off move on 28 October may not carry over; say if you want it recreated." The user confirms. The new series is inserted; the truncate returns 412 because Sam edited the description in the meantime. The tool re-reads: times, attendees and rule are unchanged, only the description differs, so it applies the truncate with the new ETag and reports success.

Failure modes

  • Searching without expansion. The agent finds a two-year-old series start and "moves" the entire history.
  • Instance edits as series edits. Looping over instances to change a series creates dozens of exceptions that later edits to the series will not touch.
  • Array patch with a partial list. Attendees or recurrence lines silently replaced; the most damaging and least visible failure.
  • Blind 412 retries. The change lands on a meeting the user never saw.
  • Floating UNTIL. A local-time UNTIL against a zoned start violates RFC 5545 and can end the series one occurrence early or late.
  • Implicit notifications. Forty guests receive an update for a typo fix.
  • Model-supplied IDs. A hallucinated or reused event ID edits the wrong meeting; handles prevent it.

Trade-offs

Handles, proposals and conditional writes make the agent slower to act and occasionally ask twice, after a 412. That friction is the point: calendar changes affect other people and are hard to undo once notifications have gone out. Splitting a series client-side is non-atomic and loses exceptions, so prefer changing the whole series when the user's intent allows it, and say so in the proposal.

Related reading: free/busy search, idempotent booking, scopes and time zones, writing function tools in ADK Java, returning tool errors the model can act on and retries and backoff for tool calls.

What to do next

  1. Change your search tool to call events.list with singleEvents=true and a bounded window, and return handles instead of event IDs.
  2. Store ID, series ID, originalStartTime and ETag per handle in session state.
  3. Add If-Match to every update, patch and delete, and turn 412 into re-read, re-plan and, if anything visible changed, re-confirm.
  4. Audit every patch body for array fields; rewrite attendees and recurrence only from a fresh read.
  5. Implement the FOLLOWING split with insert-first ordering, a UTC UNTIL and an exception report.
  6. Make sendUpdates an explicit, displayed part of every proposal, and test each scope against a sandbox calendar that has a moved occurrence and a cancelled one.
Key takeaway: Changing calendar events safely is about identity, scope, concurrency and consequence. Search with expanded instances and hand the model handles, not IDs; make every change a proposal the user confirms with its scope and notifications spelled out; write with If-Match and treat 412 as a reason to re-validate; rebuild array fields such as attendees from a fresh read; and split series insert-first with a UTC UNTIL, reporting the exceptions that will not carry over.