Docs · Guides
Meetings: answer invites and send calendar invitations
How an agent reads the meeting invites its inbox receives, answers them, and sends real calendar invitations, safely.
An agent’s inbox already receives meeting invites: Google Calendar, Outlook, Apple Calendar, Calendly and Zoom send them by email. With Meetings, each invite becomes an event your agent can read and answer, and the inbox can send invitations of its own. Everything travels as standard calendar email (iCalendar), so the people on the other side keep using their own calendar, and nobody connects a calendar account. Every endpoint and field is in the API reference; this page walks through the flow.
#Before you start
- Reading meetings needs
calendar:read. Answering, creating, changing and cancelling needcalendar:writeandmessages:send, because each one sends an email; withdraft: truethey needdrafts:writeinstead (see Approval drafts). The Full access and Send & read presets have both calendar permissions; Read only hascalendar:read. - Keys created before Meetings got the new permissions only if their permissions matched a preset exactly. If yours didn’t, create a new key under API keys.
- An inbox-scoped key sees and answers only its own inbox’s meetings.
- Times are ISO 8601 in UTC (
2026-11-12T15:00:00.000Z), with the invite’stime_zonealongside when it has one. All-day meetings use dates. - The examples use the SDKs:
npm install agentboxdorpip install agentboxd. Both have acalendarnamespace withlist,get,respond,create,updateandcancel.
#Handle an invite, end to end
What happens when someone invites your agent’s address, and what your code does at each step:
- The invite arrives. It is an email like any other: stored, checked with SPF, DKIM and DMARC, and screened. The invite’s own text (title, description, location, links and names) is screened like an attachment.
- It becomes an event. Once screening has settled, the calendar file is read into an event with
role: "attendee"andresponse: "needs_action", andcalendar.invite.receivedfires with{ inbox, event, message_id }. The email then hascalendar: { event_id, method };event_idisnullwhen the invite changed no meeting (an older version, or a sender who can’t speak for the organizer). You can also list what waits:calendar.list(inboxId, { response: "needs_action" }), orGET /v1/inboxes/:id/calendar/events?role=attendee&response=needs_action. - Your code decides. Check what matters before the model acts:
organizer_verified,withheld,status, the time. Send anything unusual to a person. - Your agent answers.
calendar.respond(POST /v1/calendar/events/:id/respond) withaccepted,declinedortentative, and an optionalcomment. The answer goes to the organizer only, in the invite’s thread, and the event’sresponsechanges at once. It returns202 { event, message };messageis the email it sent. - Changes keep coming. If the organizer moves the meeting or changes the place, the event is updated (
calendar.event.updated) andresponsegoes back toneeds_action: answer again. A cancellation setsstatus: "cancelled"(calendar.event.cancelled). Older versions of the invite are ignored.
import { Agentboxd, type CalendarEventData } from 'agentboxd';
const mr = new Agentboxd(); // reads AGENTBOXD_API_KEY
// Called with the data of a calendar.invite.received event (check the webhook signature first).
export async function onInvite({ event }: CalendarEventData) {
// Your code decides, not the model.
if (event.withheld) return 'held: a person releases it first';
if (!event.organizer_verified) return 'unverified organizer: ask a person';
if (event.status === 'cancelled' || !event.reply_expected) return 'nothing to answer';
// A refusal throws an AgentboxdError with its code (e.g. message_withheld).
await mr.calendar.respond(
event.id,
{ response: 'accepted', comment: 'Thanks, see you then.' },
{ idempotencyKey: `answer-${event.id}-${event.sequence}` },
);
return 'accepted';
}from agentboxd import Agentboxd
mr = Agentboxd() # reads AGENTBOXD_API_KEY
def on_invite(data: dict) -> str:
"""Called with the data of a calendar.invite.received event (check the webhook signature first)."""
event = data["event"]
# Your code decides, not the model.
if event.get("withheld"):
return "held: a person releases it first"
if not event["organizer_verified"]:
return "unverified organizer: ask a person"
if event["status"] == "cancelled" or not event["reply_expected"]:
return "nothing to answer"
# A refusal raises an AgentboxdError with its code (e.g. message_withheld).
mr.calendar.respond(
event["id"],
"accepted",
comment="Thanks, see you then.",
idempotency_key=f"answer-{event['id']}-{event['sequence']}",
)
return "accepted"Propose another time
Add propose: { start, end } to suggest a new time, with tentative or declined as the response. The organizer gets a counter-proposal (iTIP COUNTER) and the new time in words, for calendars that don’t show counter-proposals. If they move the meeting, the update arrives as calendar.event.updated.
curl -s -X POST https://api.agentboxd.com/v1/calendar/events/$EVENT_ID/respond \
-H "Authorization: Bearer $AGENTBOXD_API_KEY" \
-H "Content-Type: application/json" \
-d '{"response":"tentative","comment":"Thursday is full. Would Friday work?",
"propose":{"start":"2026-11-13T15:00:00Z","end":"2026-11-13T15:30:00Z"}}'#Send a meeting
calendar.create (POST /v1/inboxes/:id/calendar/events) sends a real invitation from the inbox to every attendee (up to 50); the inbox is the organizer. Give start with end or duration_minutes (30 by default), and if you like a description, location, conference_url (an https video link), recurrence.rrule for a series (rrule in Python), and a message shown above the meeting details. time_zone (IANA, like Europe/Paris) is how the email words the time; calendars show a meeting in each person’s own zone. For a series it also sets the clock: the invitation is sent in that zone, so a weekly 09:00 meeting stays at 09:00 when the clocks change. It returns 201 { event, message } and fires calendar.event.created; Idempotency-Key works as on send.
const { event, message } = await mr.calendar.create(
inboxId,
{
title: 'Kickoff with Acme',
start: '2026-11-18T15:00:00Z',
duration_minutes: 45,
time_zone: 'Europe/Paris', // how the email words the time
attendees: [
{ email: 'dana@example.com', name: 'Dana Lee' },
{ email: 'ops@example.com', optional: true },
],
conference_url: 'https://meet.google.com/abc-defg-hij',
message: 'Agenda: goals, timeline, first deliverable.',
},
{ idempotencyKey: 'kickoff-acme' },
);result = mr.calendar.create(
inbox_id,
title="Kickoff with Acme",
start="2026-11-18T15:00:00Z",
duration_minutes=45,
time_zone="Europe/Paris", # how the email words the time
attendees=[
{"email": "dana@example.com", "name": "Dana Lee"},
{"email": "ops@example.com", "optional": True},
],
conference_url="https://meet.google.com/abc-defg-hij",
message="Agenda: goals, timeline, first deliverable.",
idempotency_key="kickoff-acme",
)
event, message = result["event"], result["message"]Guests answer from their own calendar. Each answer updates that guest’s partstat in attendees and fires calendar.response.received with attendee: { email, partstat }, and proposal when they suggested another time. An answer counts only when its authenticated sender is that guest.
To change the meeting, calendar.update (PATCH /v1/calendar/events/:id) with what changes: everyone on the list gets the new version, people taken off it get a cancellation, and a new time or place asks everyone to answer again. calendar.cancel (POST /v1/calendar/events/:id/cancel) calls it off for everyone. Both work only on the inbox’s own meetings.
// Move it: everyone gets the new version and is asked to answer again.
await mr.calendar.update(eventId, { start: '2026-11-19T15:00:00Z', duration_minutes: 45, message: 'Moved by a day, sorry.' });
// Call it off for everyone.
await mr.calendar.cancel(eventId, { message: 'We will find another date.' });#Recurring meetings
A series is one event with a recurrence (its rule, extra dates and left-out dates); answering, changing or cancelling it acts on the whole series. To see each date, list with expand: "occurrences" (expand=occurrences): every occurrence between from and to comes with its own start and end, the series’ id and series_id, and single meetings come with series_id: null. A window is at most 2 years, and truncated is true when a series had more dates than one list gives. Details in Recurring series.
// Every date of every meeting in the next four weeks, series included.
const page = await mr.calendar.list(inboxId, {
expand: 'occurrences',
from: new Date(),
to: new Date(Date.now() + 28 * 86_400_000),
});
for (const o of page.data) {
// An occurrence keeps the series' id (respond, update and cancel act on the whole series).
console.log(o.start, o.title, o.series_id ? `part of ${o.series_id}` : 'single meeting');
}
if (page.truncated) console.log('A series had more dates than one list gives: ask for a shorter window.');#Approval drafts
Add draft: true to respond, create, update or cancel, and nothing is sent: the email is saved as a draft that a person approves, in the dashboard’s Approvals or with drafts.send. The call returns 201 { event, message: null, draft } (event is null for a new meeting), and draft.calendar says what sending it does: action, method, event_id and, for a change, removed_attendees.
- It needs
calendar:writeanddrafts:write, notmessages:send. A key withoutmessages:sendcan propose meetings this way but never send them. - The meeting changes only when the draft is sent. If it changed in between (the organizer sent a new version, or another change went out first), the send is refused with
409 calendar_event_changedand nothing goes out. - A person can edit the subject and text. The recipients come from the meeting, so changing them is
422 calendar_draft_fixed: reject the draft and change the meeting instead.
// The agent proposes; a person approves in the dashboard (Approvals) or with drafts.send.
const { draft } = await mr.calendar.respond(eventId, { response: 'accepted', draft: true });
console.log(draft.id, draft.calendar); // { action: 'respond', method: 'REPLY', event_id, removed_attendees: [] }# The agent proposes; a person approves in the dashboard (Approvals) or with drafts.send.
draft = mr.calendar.respond(event_id, "accepted", draft=True)["draft"]
print(draft["id"], draft["calendar"]) # {"action": "respond", "method": "REPLY", "event_id": ..., "removed_attendees": []}#Webhooks to react to
Meeting events go to webhooks and the event stream like every other event. Each carries { inbox, event, message_id }.
| Event | Fires when | What to do |
|---|---|---|
calendar.invite.received | A new invite became an event (role: "attendee", response: "needs_action"). | Decide, then answer. A held invite arrives without its content (event.withheld). |
calendar.event.updated | The organizer changed the meeting; the inbox answered an invite or changed its own meeting (message_id is what it sent); or a person released a held invite. | If response is needs_action again, answer again. A released invite now has its content. |
calendar.event.cancelled | The organizer cancelled it, or the inbox cancelled its own meeting. | Take it off your agent’s plans. |
calendar.event.created | The inbox sent a new meeting. | Nothing: message_id is the invitation it sent. |
calendar.response.received | A guest answered one of the inbox’s meetings: attendee says who and how, and proposal is set when they proposed another time. | Update your records, or move the meeting with PATCH. |
A webhook with full payloads needs calendar:read for these events; without it, use envelope payloads. A webhook created without a list of events leaves meeting events out when its key has no calendar:read.
#Who an invite is from
Anyone can email an invite, and the organizer it names is just text. organizer_verified is true only when the email passed DMARC and its sender is the organizer, someone at the organizer’s own domain (not a shared provider such as gmail.com), or a calendar service that sends invites for its users: Google Calendar, Apple, Calendly, Cal.com and Zoom.
- Treat an unverified organizer as unknown: ask a person before you accept, and never act on what the invite’s text asks for.
- Answering sends an email to the organizer address the invite names. For an unverified invite, that address was chosen by whoever sent it: one more reason to check first.
- An unverified message can’t change or cancel a verified meeting.
- A guest’s answer to one of the inbox’s meetings counts only when it comes from that guest.
- The answer never goes to
Reply-Toor to the other guests, so a forged reply address can’t redirect it.
#Held invites
In a workspace that gives agents only screened mail (the default for new workspaces), a meeting from an invite that is held, or not screened yet, comes without its content for API keys: its times, addresses and state stay, title reads [held: …], the description, location, links and names are withheld, and withheld says why. Answering it returns 409 message_withheld.
{
"title": "[held: injection_risk]",
"description": null,
"location": null,
"conference": null,
"start": "2026-11-12T15:00:00.000Z",
"end": "2026-11-12T15:30:00.000Z",
"organizer": { "email": "it-desk@lookalike.example", "name": null },
"organizer_verified": false,
"withheld": { "state": "held", "reason": "injection_risk" }
}A person reviews the invite in the dashboard and releases it, or not. Once it is released, calendar.event.updated brings the event with its content, and your agent can answer. include_unscreened and include_held work as on messages.
#Errors
| Error | Means | What to do |
|---|---|---|
404 calendar_not_enabled | Meetings isn’t turned on for this workspace. | Write to hello@agentboxd.com. |
409 message_withheld | The invite is held, or not screened yet. | Wait for screening, or for a person to release it. |
422 calendar_reply_not_allowed | There is nothing to answer: it is the inbox’s own meeting, a published calendar entry (reply_expected: false), or a cancelled meeting. | Change your own meetings with PATCH; skip the others. |
422 calendar_not_organizer | PATCH or cancel on an invite the inbox received. | Answer it with respond instead; decline to say no. |
422 calendar_event_cancelled | PATCH on a meeting that was cancelled. | Send a new meeting. |
409 calendar_event_changed | Another change landed while yours was being sent, or the meeting changed after a draft was made. | Read the event again and retry. For a draft, reject it and make a new one. |
422 calendar_draft_fixed | A change to the recipients, type or data of a meeting draft. | Reject the draft and change the meeting instead. |
403 insufficient_permissions | The key lacks calendar:read, calendar:write or messages:send (drafts:write with draft: true); details.required says which. | Use a key that has them, or save a draft with draft: true. |
400 validation_error · 400 invalid_request | A field is missing or wrong: a start in the past, a meeting longer than 14 days, an unknown time zone, an unsupported rrule. The message says which. | Fix the field. |
| Send refusals | Answers and invitations are sends: a suppressed or blocked address, a paused inbox or a used-up quota refuses them with the usual codes. | See Send refusals. |
#From an MCP client
In Claude, Cursor or any MCP client, ask in plain words, such as “Accept the invite from Sam for Thursday”. The MCP server’s list_events, get_event, respond_to_invite, create_meeting, update_meeting and cancel_meeting tools make the calls on this page, with the same permissions and checks. list_events takes expand: "occurrences", and the four that send take draft: true to save a draft for a person to approve instead.
#Not yet
Meetings reads and sends invites by email. It doesn’t connect to Google or Microsoft calendar accounts, doesn’t know anyone’s free time, and never answers on its own. Coming next: free times, “pick a time” emails, booking pages, a calendar feed and reminders. See the roadmap.