# How-to: connect email, calendar, and meetings

**Goal:** let real email, calendar entries, and attended meetings flow into
and out of the platform — through the vendor you choose, with the platform
records looking the same whichever way they arrived.

Assumes `$LYDIAN`, `$TENANT`, and `$KEY` from the [Quickstart](/docs/quickstart.html).
Everything on this page is admin-plane (API key; not bearer tokens).

---

## Provider bindings

Every touchpoint with the outside world is a **port**. A **provider binding**
names which vendor **adapter** serves a port for your tenant — at most one per
port, and a port with no binding is simply off.

| Port | Direction | Adapters | Enables |
|---|---|---|---|
| `email-outbound` | out | `resend` (default), `smtp`, `external` | communications delivered as real email |
| `email-ingest` | in | `resend-inbound` | inbound mail becomes documents |
| `calendar-ingest` | in | `google-calendar` | calendar entries mirror into events |
| `meeting-capture` | out + in | `recall`, `external` | a notetaker attends meetings; transcripts come back |
| `ai-inference` | out | `anthropic` | [AI tasks](/docs/use-ai-tasks.html) |

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/provider-bindings" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"port": "email-outbound", "adapter": "resend",
       "config": {"replyTo": "office@example.com",
                  "credentials": {"webhookSecret": "whsec_…"}}}' | jq
```

Three rules hold for every binding:

- **`config.credentials` is write-only.** Secrets are encrypted at rest and
  never returned; reads show `credentialsSet: true`. On `PUT`, leaving the
  `credentials` key out keeps the stored secrets; sending it replaces them.
- **Config is validated at write time.** A missing key or a malformed URL is
  `400 VALIDATION_FAILED` with per-field details — never a failure in a job
  later.
- **Every binding has a hook URL** — `POST /hooks/providers/{bindingId}?token=…`
  (`hookToken` in the response) — where the vendor posts its callbacks:
  bounces, bot status changes, calendar push notifications. Anonymous route,
  per-binding secret.

**The `external` adapter** is the escape hatch on every outbound port. Instead
of calling a vendor, the platform POSTs a signed webhook (`X-Lydian-Signature`
is an HMAC-SHA256 of the body with your `sharedSecret`) to your `webhookUrl`;
your app performs the action with whatever vendor it likes and reports the
outcome to the hook URL. From the platform's point of view, your app *is* the
vendor.

Deleting a binding turns the port off; queued or running jobs for it block
the delete until they finish.

## Email out

Communications stay in-platform by default. When your tenant has an enabled
`email-outbound` binding, a sent message or notification is **also delivered
as email to each recipient who opted in** — `"emailDelivery": true` on the
person record (default `false`; a client that omits the field on update keeps
the stored value). The message body is markdown, rendered to HTML; attached
documents ride along up to 10 MB each.

Delivery is per recipient and asynchronous:

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $SENDER" \
  "$LYDIAN/tenants/$TENANT/communications/$MSG/deliveries" | jq
```

```json
[{"recipientPersonId": "…", "status": "SENT", "providerRef": "re_…", "at": "…"}]
```

`QUEUED` → `SENT`, `BOUNCED`, or `FAILED`. A hard bounce flips the person's
opt-in back to `false` so you stop mailing a dead address.

## Email in

Two things first. `PUT …/email-config` names the **ingest person** — the
person ingested documents are created by (required; delete-blocked while
configured) — and the **sender policy**: `KNOWN_SENDERS` (default) rejects a
`from` that matches no person's email with `422 SENDER_REJECTED`; `OPEN`
accepts anyone.

```bash
curl -s -X PUT "$LYDIAN/tenants/$TENANT/email-config" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"senderPolicy": "KNOWN_SENDERS", "ingestPersonId": "'"$OFFICE"'"}'
```

Then the **front door** — the same one the built-in feeder uses:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/ingest/email" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"from": "alice@example.com", "to": ["office@example.com"],
       "subject": "Invoice question", "bodyText": "Where is invoice 42?",
       "tag": "billing"}' | jq
```

```json
{"documentId": "…", "attachmentDocumentIds": [], "senderPersonId": "…", "matchedPersonIds": ["…"]}
```

The email becomes a document of the seeded, immutable **"Email"** type, with
the thread rendered as markdown, `participants` set to every matched person,
and each attachment (send multipart: an `email` JSON part plus file parts) an
ordinary uploaded document linked through `attachments`. Then
`email.ingested` fires for your workflows. Unknown senders are **never**
auto-created as people.

With the `resend-inbound` adapter bound, mail sent to
`{tenantKey}+{tag}@in.platformlydian.com` arrives through this door on its
own, with `tag` set — so `billing@`, `intake@`, and so on route by workflow
filter rather than by platform rule. An app that reads its own mailbox
(Gmail, Graph, anything) posts the same JSON.

If your tenant was provisioned by an app, bind `resend-inbound` with no
credentials: the platform's own inbound webhook covers the whole ingest
domain and routes each message to the tenant named by the recipient's local
part, so `{tenantKey}+tag@…` works without a Resend account of your own.

## Calendar

`PUT …/calendar-config` first: `actingPersonId` (who connector-created
records are attributed to; required, delete-blocked) and `captureByDefault`
(default `true` — see Meetings below).

The front door is an **upsert on `externalId`**:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/ingest/calendar-events" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"externalId": "gcal-abc", "status": "confirmed",
       "name": "Design review",
       "startAt": "2026-10-01T15:00:00Z", "endAt": "2026-10-01T15:30:00Z",
       "joinUrl": "https://meet.google.com/abc-defg-hij",
       "organizerEmail": "alice@example.com",
       "attendees": [{"email": "alice@example.com", "role": "organizer", "rsvp": "accepted"},
                     {"email": "guest@example.com"}]}' | jq
```

```json
{"calendarLinkId": "…", "eventId": "…", "status": "LIVE", "action": "CREATED",
 "matchedPersonIds": ["…"], "unmatchedAttendees": [{"email": "guest@example.com"}],
 "captureStatus": null}
```

What happens, identically for every feeder:

- The first post creates an event of the seeded **"Meeting"** type; attendees
  whose email matches a person become participants (organizer → `ORGANIZER`,
  `rsvp` mapped to RSVP status); the rest are kept raw on the link.
- The same `externalId` again **updates** the event (`action: "UPDATED"`).
- `"status": "cancelled"` **deletes** the event — unless something references
  it (a transcript, an invoice line), in which case the event stays and the
  link becomes `ORPHANED`. A billed or noted meeting outlives its calendar
  entry.
- While a link is `LIVE`, the event cannot be deleted through `/events`
  (`409 REFERENCE_BLOCKED`); cancel it in the calendar instead.
- An occurrence of a recurring meeting may carry `"seriesId"`, the feeder's id
  for the series (the `google-calendar` adapter sends Google's
  `recurringEventId`). Occurrences stay independent events; the id only groups
  them, and `GET …/calendar-links?seriesId=…` lists one series.

`GET …/calendar-links?status=LIVE` lists the mirror records. The mirror is
**one-way**: the platform never writes to a calendar.

**The built-in feeder.** With a `google-calendar` binding, your admin connects
a Google account once (the platform mints a consent URL; the refresh token is
stored encrypted on the binding). From then on the platform syncs
incrementally and mirrors **only the calendar entries that invite the
tenant's notetaker address**, `{tenantKey}+meet@in.platformlydian.com` — so
anyone who can edit a calendar entry can summon the platform, and you never
call the front door yourself. A Microsoft 365 shop, or an app with its own
calendar sync, posts to the front door instead and gets the same records.

The mirror looks **90 days ahead**. A recurring series invites the notetaker
once but Google expands it into every occurrence (up to 730 of them); only the
occurrences inside the horizon are mirrored, and the rest arrive as the
horizon moves forward, a day at a time. Mirrored meetings do not send
"added to" notifications to their participants: the calendar invite already
told them.

## Meetings

A `LIVE` link with a `joinUrl`, capture enabled, and an enabled
`meeting-capture` binding asks the bound adapter to **attend the meeting**.
The link's `captureStatus` tracks it:

```
SCHEDULED ──► JOINED ──► CAPTURED
    │            │
    └────────────┴──► FAILED        (or CANCELLED: meeting cancelled / opted out)
```

The link reads `SCHEDULED` as soon as it is mirrored, but the adapter is asked
to attend only **24 hours before the meeting**, so the vendor never holds
bots for meetings months away. A vendor rate limit pauses the request and it
tries again later; it does not fail the capture.

Opt a single meeting out (or back in) with
`PUT …/calendar-links/{id}/capture {"enabled": false}`; the tenant default is
`captureByDefault` in the calendar config.

Which notetaker attends is the binding's business: the `recall` adapter uses
a hosted bot vendor (a Recall.ai-class API that joins Meet, Zoom, and Teams as
a participant, records, and transcribes). A `recall` binding with no
`credentials` runs on the **platform's** vendor account — nothing to sign up
for — and its bots and minutes count against the tenant's monthly allowance;
a binding carrying its own `apiKey`/`webhookSecret` runs on your account and is
metered but never bounded ([Usage &amp; entitlements](/docs/usage-and-entitlements.html)).
The `external` adapter webhooks the request to your app and your own notetaker
attends.

Either way the transcript comes back through **one front door** — the vendor
adapter calls it for you, or your app does:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/ingest/meeting-captures" \
  -H "X-API-Key: $KEY" -H "Content-Type: application/json" \
  -d '{"externalId": "gcal-abc",
       "transcript": {"format": "segments",
                      "segments": [{"speaker": "Alice", "startMs": 5000, "text": "Let us begin."},
                                   {"speaker": "Bob", "startMs": 65000, "text": "Agreed."}]},
       "recordingUrl": "https://vendor.example.com/rec/1", "durationMinutes": 30}' | jq
```

```json
{"eventId": "…", "calendarLinkId": "…", "transcriptDocumentId": "…",
 "participantPersonIds": ["…"], "durationMinutes": 30}
```

Address the meeting by `externalId`, `calendarLinkId`, or a bare `eventId`
(an unprompted capture of an event that was never mirrored). `format:
"markdown"` with `content` is stored verbatim; segments render to
speaker-labelled markdown. The result is a document of the seeded
**"Transcript"** type — `participants` copied from the event, `meeting`
pointing back at it — the Meeting's `transcript` attribute set, the link
`CAPTURED`, and `meeting.captured` fired.

## Everything after the transcript is yours

The platform stops at the transcript on purpose. Notes, billable hours,
follow-ups, notifications — different tenants want different things, so they
are a [workflow](/docs/automate-with-workflows.html) on `meeting.captured`:

```json
{"name": "Meeting follow-up",
 "description": "notes, hours, and a heads-up after every captured meeting",
 "trigger": {"type": "DOMAIN_EVENT", "eventType": "meeting.captured"},
 "actingPersonId": "…",
 "steps": [
   {"kind": "ai_task", "config": {"kind": "summarize", "documentIds": ["{{transcriptDocumentId}}"],
                                  "outputDocument": {"title": "Notes: {{eventName}}"}}},
   {"kind": "link", "config": {"recordKind": "event", "recordId": "{{eventId}}",
                               "attributeId": "<notes attribute id>", "value": "{{outputDocumentId}}"}},
   {"kind": "update_record", "config": {"recordKind": "event", "recordId": "{{eventId}}",
                                        "attributeValues": {"<hours attribute id>": "{{durationMinutes}}"}}},
   {"kind": "send_notification", "config": {"recipientPersonIds": "{{participantPersonIds}}",
                                            "subject": "Notes ready: {{eventName}}"}}
 ]}
```

Invite the platform to a meeting; the notes, the hours on the event, and the
"notes ready" notification appear on their own.
