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.
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 reasonTwo 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, ornpm install -g swytchcode, thenswy 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
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 doctorTwo 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
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_meexists sodeclinedoesn't absorb the uncertainty. Without it, anything confusing drifts toward declining, which is the one outcome you can't quietly undo.needs_me_specificallyis 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_externalis a routing signal, not a decision input. Anything from a client gets a human regardless of how clear it looks.conflictsis 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.jsOn 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.createand let a person tap. This is the version to start with on a shared work calendar. - Add a "suggest a different time" path. When
conflictsis at the top of the scale andneeds_me_specificallyis 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
OpenAI Dots vs Instinct: Two Ways to Build a Personal Agent
Two personal-agent products launched within a week of each other and solved the same problem differently. Dots gives an agent its own computer inside your workspace; Instinct sends yours into other people's group chats. Both had to invent a permission layer, and neither exposes one to developers.
Jev Confidence Scores: How to Pick Thresholds Before Your Agent Acts
Jev hands back a number with every answer, and most people pick 0.8 because it looks reasonable. Here is what the number actually measures, why choice confidence means different things at three options and thirty, and how to find your own thresholds from logged outcomes.
Jev MCP Server: How to Use Jev Inside Claude Code, Cursor and Codex
TypeSafe doesn't ship an MCP server for Jev. What exists is Swytchcode's MCP server, which exposes Jev alongside every other integration as tools your coding agent can call. Here is the setup for Claude Code, Cursor, Codex and anything else that speaks MCP.
