# Meetings: answer invites and send calendar invitations

Source: https://agentboxd.com/docs/meetings

> 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](https://agentboxd.com/docs/api#meetings); 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](https://agentboxd.com/docs/meetings#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](https://agentboxd.com/app/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](https://agentboxd.com/docs/api#screening). 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:

```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:

```python
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:

```bash
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:

```ts
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:

```python
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:

```ts
// 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](https://agentboxd.com/docs/api#recurring-series).

the next four weeks, date by date:

```ts
// 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](https://agentboxd.com/docs/drafts#meeting-drafts) 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:

```ts
// 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:

```python
# 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](https://agentboxd.com/docs/webhooks) and the [event stream](https://agentboxd.com/docs/realtime-events) 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](https://agentboxd.com/docs/webhooks#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):

```json
{
  "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](https://agentboxd.com/docs/api#screening-reads).

## 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](https://agentboxd.com/docs/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](https://agentboxd.com/docs/mcp)’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](https://agentboxd.com/roadmap).
