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.

View as Markdown

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 need calendar:write and messages:send, because each one sends an email; with draft: true they need drafts:write instead (see Approval drafts). The Full access and Send & read presets have both calendar permissions; Read only has calendar: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’s time_zone alongside when it has one. All-day meetings use dates.
  • The examples use the SDKs: npm install agentboxd or pip install agentboxd. Both have a calendar namespace with list, get, respond, create, update and cancel.

#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" and response: "needs_action", and calendar.invite.received fires with { inbox, event, message_id }. The email then has calendar: { event_id, method }; event_id is null when 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" }), or GET /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) with accepted, declined or tentative, and an optional comment. The answer goes to the organizer only, in the invite’s thread, and the event’s response changes at once. It returns 202 { event, message }; message is the email it sent.
  • Changes keep coming. If the organizer moves the meeting or changes the place, the event is updated (calendar.event.updated) and response goes back to needs_action: answer again. A cancellation sets status: "cancelled" (calendar.event.cancelled). Older versions of the invite are ignored.
on-invite.ts
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';
}
on_invite.py
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.

POST /v1/calendar/events/:id/respond
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.

create a meeting
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' },
);
create a meeting
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.

change or cancel
// 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.

the next four weeks, date by date
// 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:write and drafts:write, not messages:send. A key without messages:send can 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_changed and 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.
answer for a person to approve
// 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: [] }
answer for a person to approve
# 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 }.

EventFires whenWhat to do
calendar.invite.receivedA new invite became an event (role: "attendee", response: "needs_action").Decide, then answer. A held invite arrives without its content (event.withheld).
calendar.event.updatedThe 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.cancelledThe organizer cancelled it, or the inbox cancelled its own meeting.Take it off your agent’s plans.
calendar.event.createdThe inbox sent a new meeting.Nothing: message_id is the invitation it sent.
calendar.response.receivedA 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-To or 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.

a held invite, as an API key reads it (trimmed)
{
  "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

ErrorMeansWhat to do
404 calendar_not_enabledMeetings isn’t turned on for this workspace.Write to hello@agentboxd.com.
409 message_withheldThe invite is held, or not screened yet.Wait for screening, or for a person to release it.
422 calendar_reply_not_allowedThere 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_organizerPATCH or cancel on an invite the inbox received.Answer it with respond instead; decline to say no.
422 calendar_event_cancelledPATCH on a meeting that was cancelled.Send a new meeting.
409 calendar_event_changedAnother 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_fixedA change to the recipients, type or data of a meeting draft.Reject the draft and change the meeting instead.
403 insufficient_permissionsThe 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_requestA 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 refusalsAnswers 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.