Content

How to Let Jev Triage Your Google Calendar Invites (Accept, Decline, or Ask)

Google Calendar has no accept or decline endpoint. RSVP is an event update that changes your own attendee record, which is a detail most tutorials get wrong. Here is a working invite triage agent with Jev deciding and Swytchcode doing the update.

Chaitrali Kakde

DevRel Engineer

AI AgentOct 17, 2026

Key takeaways

  • -There is no accept or decline API in Google Calendar. You RSVP by updating the event and setting responseStatus on your own attendee entry, via calendar.event.update.
  • -Jev answers four questions per invite in one call: what to do, whether you specifically are needed, how much it conflicts, and whether the organiser is external.
  • -Never auto-decline. Accept and tentative are cheap to undo; a declined client meeting is a phone call you have to make.
  • -Send the invite plus that day's existing events as state, because an invite is only a conflict relative to a calendar Jev cannot see.
  • -Only calendar.event.get and calendar.event.update go in tooling.json, so the agent cannot delete events or create new ones.

Here's a thing we got wrong for an hour: we went looking for Google Calendar's accept endpoint.

It doesn't exist. Microsoft Graph has one, microsoft.me.accept.create, a real endpoint whose whole job is accepting an event. Google doesn't. In Google Calendar, RSVP is a side effect of updating the event: you fetch it, find your own entry in the attendees array, set responseStatus to accepted, declined or tentative, and write the event back.

That difference shapes the whole build, because updating an event is a much bigger hammer than accepting one. You're writing to an object other people share. Get the payload wrong and you're not declining a meeting, you're editing somebody else's invite.

So this post is an invite triage agent where Jev decides and Swytchcode does the write, with the care that write deserves.

What you'll build

Google Calendar, next 7 days
   │  swytchcode exec calendar.event.get        (list events)
   ▼
invites awaiting response  +  that day's existing events
   │
   ▼
Jev: 4 questions in one call
   │
   ▼
{ action, needs_me_specifically, conflicts, organiser_is_external } + confidence
   │
   ├─ accept,   confident ──> swytchcode exec calendar.event.update  (responseStatus: accepted)
   ├─ tentative            ──> same call, responseStatus: tentative
   ├─ decline              ──> print only, never automatic
   └─ unsure / external    ──> leave for you, with a reason

Two pieces, as in the rest of this series:

  • Jev decides. It never touches your calendar.
  • Swytchcode runs every call, holds the OAuth token, and only allows the two methods you added.

Why Jev rather than an LLM

An invite decision is a choice from four options with a confidence number attached, which is the shape Jev exists for. Specifically:

The answer is always one of your four. No invented fifth state, no prose explaining its reasoning when you wanted a value to branch on.

It's fast enough to run on every invite as it arrives. 70 to 500 ms per call, so this can run on a webhook rather than a nightly sweep.

You get a number, which is what makes declining safe to withhold. A confident accept on a recurring standup is fine. A 0.6 on something from an address you don't recognise is exactly the case you want left alone.

What you need

  • Node.js 20 or newer.
  • A Jev API key. From console.typesafe.ai or Vercel's AI Gateway, see our access guide. It goes into Swytchcode once, not your code.
  • The Swytchcode CLI. npx swytchcode, or npm install -g swytchcode, then swy login.
  • A Google account. Use a secondary one for the first few runs. This agent writes to real events.

Step 1: Set up the project

mkdir invite-triage && cd invite-triage
npm init -y && npm pkg set type=module
npm install @swytchcode/runtime
swy init --mode=sandbox
Terminal running swytchcode init and choosing an editor and execution mode

swytchcode init asks which editor you use and whether to run in production or sandbox mode. Start in sandbox.

swy init writes .swytchcode/tooling.json, which holds the mode. There's no .env file here: credentials live in Swytchcode's own store at ~/.swytchcode/credentials.db, outside the project.

Step 2: Connect Jev and Calendar

swy get jev
swy get calendar

swy add method <jev-method-id>        # find it with: swy list methods jev
swy add method calendar.event.get     # list events for a calendar
swy add method calendar.event.update  # update an event, including your RSVP

swy auth connect jev        # asks for your Jev API key
swy auth connect calendar   # opens Google's sign-in in your browser
swy doctor

Two calendar methods, and note what's missing: no delete, no create, no sharing changes. The agent can read your events and update them. That's the entire blast radius, enforced below your code: if a bug asks for anything else, Swytchcode refuses. The config files post covers how the allowlist works.

Confirm the shapes before writing against them, because the Google Calendar integration is v3 and the naming doesn't always match what you'd guess:

swy discover "update a google calendar event" --library calendar
swy info calendar.event.update
Claude Code running swytchcode list and swytchcode info commands to find the right methods before executing

swytchcode info prints a method's inputs and outputs, which is how you avoid writing code against a guessed payload.

One naming quirk worth flagging: calendar.event.get lists the events on a calendar rather than fetching one by ID, which mirrors Google's events.list. You'll also see calendar.event.update1 and calendar.calendar.events.update in discovery output, overlapping operations from the import, same as Gmail's messages.get and messages.get1. swy info is how you pick.

Step 3: Read the invites and the context around them

The important design choice in this build is in the state, not the questions. An invite is not interesting on its own; it's interesting relative to what else is on that day. Jev can't see your calendar, so you have to put the relevant part of it in the state.

// src/calendar.js
import { exec } from "@swytchcode/runtime";

const CALENDAR_ID = "primary";

export async function listEvents({ days = 7 } = {}) {
  const now = new Date();
  const end = new Date(now.getTime() + days * 86400000);

  const res = await exec("calendar.event.get", {
    params: {
      calendarId: CALENDAR_ID,
      timeMin: now.toISOString(),
      timeMax: end.toISOString(),
      singleEvents: true,
      orderBy: "startTime",
      maxResults: 100,
    },
  });
  return res.items || [];
}

/** Invites where my own attendee entry is still needsAction. */
export function awaitingResponse(events, myEmail) {
  return events.filter((e) =>
    (e.attendees || []).some((a) => a.email === myEmail && a.responseStatus === "needsAction")
  );
}

/** What else is already on the same day, so Jev can judge conflicts. */
export function sameDayEvents(events, event, myEmail) {
  const day = (event.start?.dateTime || event.start?.date || "").slice(0, 10);
  return events.filter((e) => {
    if (e.id === event.id) return false;
    const d = (e.start?.dateTime || e.start?.date || "").slice(0, 10);
    if (d !== day) return false;
    const me = (e.attendees || []).find((a) => a.email === myEmail);
    return !me || me.responseStatus === "accepted";
  });
}

And the state builder:

// src/decisions.js
export function inviteToState(event, sameDay, myEmail) {
  const organiser = event.organizer?.email ?? "unknown";
  const attendees = (event.attendees || []).length;
  const start = event.start?.dateTime || event.start?.date;

  const conflicts = sameDay.length
    ? sameDay
        .map((e) => `  - ${e.summary ?? "(untitled)"} at ${e.start?.dateTime ?? e.start?.date}`)
        .join("\n")
    : "  (nothing else that day)";

  return [
    `Invite: ${event.summary ?? "(no title)"}`,
    `Organiser: ${organiser}`,
    `Starts: ${start}`,
    `Attendees: ${attendees}`,
    `My email: ${myEmail}`,
    "",
    `Description: ${(event.description ?? "").slice(0, 1500)}`,
    "",
    "Already on my calendar that day:",
    conflicts,
  ].join("\n");
}

Step 4: The questions

// src/decisions.js (continued)
export function buildQuestions() {
  return {
    action: {
      type: "choice",
      instructions:
        "How should the recipient respond to this meeting invitation? " +
        "The recipient is a practising chartered accountant who runs their own practice.",
      criteria: {
        accept: "Clearly relevant, the recipient should attend",
        tentative: "Probably relevant but conflicts or lacks detail",
        decline: "Not relevant to the recipient, or a duplicate of something else",
        ask_me: "Cannot be judged without knowing more than the invite says",
      },
    },

    needs_me_specifically: {
      type: "noul",
      instructions:
        "This meeting needs this person specifically, rather than anyone from their team. " +
        "Signals: they are named in the description, they are the only attendee besides the " +
        "organiser, or the subject is something only they can decide or sign.",
    },

    organiser_is_external: {
      type: "noul",
      instructions:
        "The organiser appears to be a client, a government body, or someone outside " +
        "the recipient's own organisation, rather than a colleague.",
    },

    conflicts: {
      type: "score",
      instructions:
        "How badly does this invite clash with what is already on the calendar that day?",
      criteria: ["No clash", "Tight but workable", "Overlaps something minor", "Overlaps something important", "Direct conflict with a client commitment"],
    },
  };
}

What's deliberate here:

  • ask_me exists so decline doesn't absorb the uncertainty. Without it, anything confusing drifts toward declining, which is the one outcome you can't quietly undo.
  • needs_me_specifically is separate from the action. It's the question that distinguishes "my team should be at this" from "I should be at this", and it's the one that makes the agent useful rather than just fast.
  • organiser_is_external is a routing signal, not a decision input. Anything from a client gets a human regardless of how clear it looks.
  • conflicts is a score, not a boolean, because "tight but workable" and "direct clash with a client" deserve different handling and a yes/no throws that away.

Step 5: Ask Jev

// src/jev.js
import { exec } from "@swytchcode/runtime";

// Find yours with: swy list methods jev
const JEV_METHOD = "<jev-method-id>";

export async function decide(state, questions, model = "jev-latest") {
  return exec(JEV_METHOD, { body: { model, state, questions } });
}

export function readAnswers(answers) {
  return {
    action: answers.action.choice,
    action_confidence: answers.action.confidence,
    needs_me: answers.needs_me_specifically.noul,
    external: answers.organiser_is_external.noul,
    clash_label: answers.conflicts.legend[String(Math.round(answers.conflicts.score))],
    clash_score: answers.conflicts.score,
  };
}

Step 6: The RSVP call, done carefully

This is the part that deserves attention. You are updating a shared object, so change exactly one field and nothing else.

// src/calendar.js (continued)
export async function rsvp(event, myEmail, responseStatus) {
  if (!["accepted", "declined", "tentative"].includes(responseStatus)) {
    throw new Error(`refusing to set responseStatus=${responseStatus}`);
  }

  // Copy the attendee list, changing only my own entry.
  const attendees = (event.attendees || []).map((a) =>
    a.email === myEmail ? { ...a, responseStatus } : a
  );

  if (!attendees.some((a) => a.email === myEmail)) {
    throw new Error("I am not an attendee on this event; refusing to update it");
  }

  return exec("calendar.event.update", {
    params: {
      calendarId: CALENDAR_ID,
      eventId: event.id,
      sendUpdates: "none",   // don't email everyone about an RSVP
    },
    body: {
      ...event,
      attendees,
    },
  });
}

Three guards in twenty lines, and each one is there because of a way this can go wrong:

The responseStatus allowlist. A typo that writes "accept" instead of "accepted" would be silently meaningless to Google. Fail loudly instead.

The "am I actually an attendee" check. If your email isn't in the list, this isn't your invite to respond to, and writing the event back would be an edit to someone else's meeting for no reason.

sendUpdates: "none". Without this, an update can notify every attendee. Nobody needs an email because you RSVPed. This is the setting that stops a test run from spamming thirty people.

The spread of ...event is a trade-off worth naming: calendar.event.update is a full update, so you send the event back as it was with your one change. The risk is that you write back a stale copy if the organiser edited it in the meantime. For RSVP triage running minutes after an invite arrives, that window is small and acceptable. If you're bothered by it, re-list the event immediately before the write and use the fresh copy.

Step 7: The rules

// src/plan.js
const CONFIDENT = 0.75;

export function planFor(a) {
  // 1. Never decline automatically. Ever.
  if (a.action === "decline") {
    return { rsvp: null, reason: `Jev suggests declining (${a.clash_label}), left for you` };
  }

  // 2. Anything from a client or government body is yours to answer.
  if (a.external >= 0.5) {
    return { rsvp: null, reason: "organiser looks external, left for you" };
  }

  // 3. Jev's own escape hatch.
  if (a.action === "ask_me") {
    return { rsvp: null, reason: "not judgeable from the invite" };
  }

  // 4. Unsure means untouched.
  if (a.action_confidence < CONFIDENT) {
    return { rsvp: null, reason: `low confidence (${a.action_confidence.toFixed(2)})` };
  }

  // 5. A real clash becomes tentative rather than accepted.
  if (a.action === "accept" && a.clash_score >= 3) {
    return { rsvp: "tentative", reason: `accepting tentatively (${a.clash_label})` };
  }

  if (a.action === "accept") return { rsvp: "accepted", reason: "clear accept" };
  if (a.action === "tentative") return { rsvp: "tentative", reason: "tentative" };

  return { rsvp: null, reason: "no rule matched" };
}

Rule 1 is the whole safety model. The agent will never decline anything. It can accept, it can mark tentative, and it can leave something alone with a reason printed. Accepting a meeting you didn't need costs you an hour. Declining a client's meeting because a model misread the description costs you an apologetic phone call, and you won't know it happened until they mention it.

That asymmetry is the point. We've written more about it in human-in-the-loop approval, and about picking the 0.75 from your own data rather than ours in confidence thresholds.

Step 8: Run it

// index.js
import { listEvents, awaitingResponse, sameDayEvents, rsvp } from "./src/calendar.js";
import { buildQuestions, inviteToState } from "./src/decisions.js";
import { decide, readAnswers } from "./src/jev.js";
import { planFor } from "./src/plan.js";

const MY_EMAIL = "[email protected]";
const apply = process.argv.includes("--apply");
const questions = buildQuestions();

const events = await listEvents({ days: 7 });
const invites = awaitingResponse(events, MY_EMAIL);
console.log(`${invites.length} invites awaiting a response\n`);

let tokens = 0;

for (const invite of invites) {
  const sameDay = sameDayEvents(events, invite, MY_EMAIL);
  const { answers, usage } = await decide(
    inviteToState(invite, sameDay, MY_EMAIL),
    questions
  );
  const a = readAnswers(answers);
  const plan = planFor(a);
  tokens += usage?.input_tokens ?? 0;

  const tag = (plan.rsvp ?? "leave").toUpperCase().padEnd(9);
  console.log(`[${tag}] ${invite.summary ?? "(no title)"}`);
  console.log(`            ${plan.reason}`);
  console.log(`            needs me: ${a.needs_me.toFixed(2)}  clash: ${a.clash_label}`);

  if (apply && plan.rsvp) {
    await rsvp(invite, MY_EMAIL, plan.rsvp);
  }
}

console.log(`\n--- ~${tokens} Jev input tokens. ${apply ? "RSVPs sent." : "Dry run."} ---`);
node index.js

On a week with 11 pending invites, that printed:

11 invites awaiting a response

[ACCEPTED ] Weekly practice sync
            clear accept
            needs me: 0.88  clash: No clash
[TENTATIVE] GST portal walkthrough (vendor demo)
            accepting tentatively (Overlaps something important)
            needs me: 0.41  clash: Overlaps something important
[LEAVE    ] Re: audit timelines, call?
            organiser looks external, left for you
            needs me: 0.93  clash: Tight but workable
[LEAVE    ] Team offsite planning
            Jev suggests declining (No clash), left for you
            needs me: 0.12  clash: No clash
[ACCEPTED ] Monthly review with staff
            clear accept
            needs me: 0.79  clash: No clash

--- ~4180 Jev input tokens. Dry run. ---

Five of eleven got a decision, four were left with a reason, and the one Jev wanted to decline was printed rather than declined. That ratio is the agent working correctly, not underperforming.

About 4,180 tokens for 11 invites. At $0.042 per million that's $0.00018, and the state here is larger than most because it carries the day's other events.

Read a week of dry-run output against your real calendar before passing --apply.

Make it safer before pointing it at your work calendar

Test on a secondary account. Create three invites, run it, check what moved.

Keep sendUpdates: "none" until you're sure. It's the difference between a quiet mistake and thirty people getting an email about it.

Stay in sandbox. Mode lives in .swytchcode/tooling.json; move to production when the dry runs stop surprising you.

Add a policy if you ever allow declines. We don't recommend it, but if you do, put a human approval policy on calendar.event.update first so each one is confirmed. Policy guardrails covers the syntax.

Check the audit log. swy audit network lists every calendar call. After an unattended run that's how you see what it touched.

Things we got wrong the first time

We looked for an accept endpoint. Google doesn't have one. Microsoft Graph does, which is where the confusion comes from: microsoft.me.accept.create is real, and the Google equivalent is an event update. Twenty minutes of swy discover would have told us.

We sent the invite with no calendar context. Jev was being asked whether a meeting conflicts, without being shown the calendar. The conflict answers were noise until we added the day's other events to the state, which seems obvious written down and wasn't at the time.

Our first run emailed everyone. We omitted sendUpdates, and a handful of attendees got notified about RSVPs on a test calendar. Harmless, embarrassing, entirely avoidable.

We let it decline. For about an hour. It declined an invite from a client whose description was one line, and that was the end of that feature.

We trusted needs_me_specifically too early. It's the most useful answer in the set and also the one most sensitive to wording. Our first version asked "is this meeting important", which Jev answered reasonably and which told us nothing about whether the recipient personally had to be there.

Where to go from here

  • Run it on a webhook rather than a sweep. Google Calendar push notifications mean you RSVP within seconds of the invite arriving.
  • Propose rather than act. Post the suggested RSVP to Slack with slack_web.chat.postmessage.create and let a person tap. This is the version to start with on a shared work calendar.
  • Add a "suggest a different time" path. When conflicts is at the top of the scale and needs_me_specifically is high, the right answer usually isn't tentative, it's rescheduling.
  • Feed outcomes back. Log Jev's answer against what you actually did, bucket by confidence, and set your real threshold from that.

The Jev guide covers eight use cases across Gmail, Drive, Slack, GitHub, Stripe, Intercom and Salesforce, and the examples repo has a Gmail and Calendar demo to borrow from.

FAQ

Does Google Calendar have an accept or decline API?

No. You RSVP by updating the event and setting responseStatus on your own entry in the attendees array. Microsoft Graph does have dedicated accept and decline endpoints, which is a common source of confusion.

Which methods do I need?

calendar.event.get to list events and calendar.event.update to set your RSVP. Confirm with swy discover and swy info for your fetched version.

Will attendees get emailed when the agent RSVPs?

Not if you pass sendUpdates: "none". Without it, they can be.

Can the agent delete or create events?

No. Only the two methods in tooling.json can run.

Does it decline meetings on its own?

No, by design. It accepts, marks tentative, or leaves the invite alone with a printed reason. Declining is yours.

Why send the whole event back in the update?

calendar.event.update is a full update, so you send the event as it was with your single change. Re-list the event immediately before writing if you're worried about a stale copy.

Where does my Google token go?

Into Swytchcode's credential store via swy auth connect calendar, outside your project. There's no .env file.

How much does a week of invites cost?

Our 11-invite run used about 4,180 input tokens, under a hundredth of a cent at $0.042 per million.

Wrapping up

The mechanism is the lesson here. There's no accept button behind Google's API, just an event you edit carefully, and the care is most of the code: change one field, confirm you're an attendee, don't email anyone, and never decline.

Jev supplies the judgement that makes it worth automating, with a number attached so the agent knows when to stop. Swytchcode supplies the two-method allowlist that means the worst case is a wrongly accepted meeting rather than a deleted calendar.

Start with the Google Calendar integration and the Jev integration, or point your coding agent at our skills file and ask it to build invite triage with Jev.

More content