# How-to: work with the inbox

**Goal:** send a message, read and reply from the recipient's side, track read
state, and handle the notifications the platform sends on its own.

Assumes the [Quickstart](/docs/quickstart.html) (`$LYDIAN`, `$TENANT`, `$KEY`
exported) and **two people** in your tenant — communications are conversations,
so you need both ends:

```bash
export TEACHER=FIRST_PERSON_ID
export STUDENT=SECOND_PERSON_ID
```

Every communications endpoint (except `/communication-types`) is scoped to a
person via the `X-Acting-Person` header — the "logged-in user" whose inbox,
sent items, and read state you're looking at. Communications never leave the
platform: no email or SMS is involved.

---

## 1. Draft and send a message

Messages start as editable drafts. Create one from the teacher:

```bash
MSG=$(curl -s -X POST "$LYDIAN/tenants/$TENANT/communications" \
  -H "X-API-Key: $KEY" -H "X-Acting-Person: $TEACHER" \
  -H "Content-Type: application/json" \
  -d "{
        \"subject\": \"Recital on September 12\",
        \"body\": \"Doors at 18:00, players ready by 17:30. Bring your own stand.\",
        \"recipientPersonIds\": [\"$STUDENT\"]
      }" | jq -r .id)
```

With no `communicationTypeId` the draft lands on the seeded "Base Message"
type. While it's a `DRAFT` you can `PUT` edits or `DELETE` it — only the
sender can. Then send:

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

```json
{
  "id": "0198c3a1-6b0d-7c33-9e51-2f8a4d6e0b17",
  "tenantId": "019fb5a2-9626-7ba6-ad59-b5f98cf04c8e",
  "communicationTypeId": "0198c09c-5f02-7b19-8d34-c6e1a9f3b528",
  "communicationTypeVersion": {"major": 1, "minor": 0},
  "channel": "MESSAGE",
  "status": "SENT",
  "senderPersonId": "0198c0f0-2a44-7b6e-91c3-d5e8f7a2b019",
  "source": "USER",
  "subject": "Recital on September 12",
  "body": "Doors at 18:00, players ready by 17:30. Bring your own stand.",
  "recipients": [{"personId": "0198c0e5-1f60-7e88-a3d4-92c7b81e6f42"}],
  "threadId": "0198c3a1-6b0d-7c33-9e51-2f8a4d6e0b17",
  "sentAt": "2026-08-05T17:11:29.664Z"
}
```

`SENT` is terminal and permanent — like real email, a sent message can never
be edited or deleted, only its recipients' read state changes. Note
`threadId`: for a thread root it equals the message's own id; every reply will
share it. A recipient with no `readAt` is unread.

## 2. The recipient's view

Switch the acting person to the student. The inbox is everything addressed to
them, and the unread count is the cheap thing to poll:

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $STUDENT" \
  "$LYDIAN/tenants/$TENANT/communications/unread-count"
# {"count": 1}

curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $STUDENT" \
  "$LYDIAN/tenants/$TENANT/communications/inbox?unreadOnly=true" | jq 'map({id, subject, channel, source})'
```

Read state is per-recipient and explicit — display is your app's business,
so tell the platform when the user has seen it:

```bash
curl -s -X POST -H "X-API-Key: $KEY" -H "X-Acting-Person: $STUDENT" \
  "$LYDIAN/tenants/$TENANT/communications/$MSG/read" | jq '.recipients'
# [{"personId": "…", "readAt": "2026-08-05T17:15:02.881Z"}]
```

(`…/unread` clears it again. Marking read requires being a recipient;
senders see read state on the record itself.)

## 3. Reply — and follow the thread

`reply` creates **and sends** in one call, defaulting sensibly: subject gets
`Re: `, recipients default to the parent's participants minus the replier,
the type defaults to Base Message:

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/communications/$MSG/reply" \
  -H "X-API-Key: $KEY" -H "X-Acting-Person: $STUDENT" \
  -H "Content-Type: application/json" \
  -d '{"body": "Will there be a shared cello available?"}' \
  | jq '{subject, parentId, threadId, status}'
```

```json
{
  "subject": "Re: Recital on September 12",
  "parentId": "0198c3a1-6b0d-7c33-9e51-2f8a4d6e0b17",
  "threadId": "0198c3a1-6b0d-7c33-9e51-2f8a4d6e0b17",
  "status": "SENT"
}
```

(If you want to compose a reply without sending yet, `POST /communications`
with `parentId` instead — it stays a draft until you `send` it.)

A thread is every record sharing one `threadId`, ordered by `sentAt` — fetch
it from either side:

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $TEACHER" \
  "$LYDIAN/tenants/$TENANT/communications/threads/$MSG" | jq 'map({subject, senderPersonId, sentAt})'
```

Attachments ride along by document id: any create/reply body accepts
`"attachmentDocumentIds": ["…"]` referencing documents in your tenant (see
[manage a document](/docs/manage-a-document.html)).

## 4. Notifications — including ones you didn't send

The second channel, `NOTIFICATION`, is for brief one-shot announcements: born
`SENT` (no draft stage), and — unlike messages — dismissable
(`DELETE /communications/{id}`) by any participant: each recipient, and for
user-sent notifications the sender too.

```bash
curl -s -X POST "$LYDIAN/tenants/$TENANT/communications/notifications" \
  -H "X-API-Key: $KEY" -H "X-Acting-Person: $TEACHER" \
  -H "Content-Type: application/json" \
  -d "{\"subject\": \"Studio closed Friday\", \"recipientPersonIds\": [\"$STUDENT\"]}"
```

More importantly, **the platform emits notifications by itself** on three
triggers:

| Trigger | Who gets notified |
|---|---|
| an invoice is issued | the invoice's recipient |
| a document is checked in | people with roles on that document |
| a person is added as an event participant | the added participant |

These arrive in inboxes with `"source": "SYSTEM"` and **no `senderPersonId`**.
Your inbox UI will encounter them from day one (creating an event with
participants is enough), so decide early how to render them — and filter on
`source` if a view should only show human messages:

```bash
curl -s -H "X-API-Key: $KEY" -H "X-Acting-Person: $STUDENT" \
  "$LYDIAN/tenants/$TENANT/communications/inbox?channel=NOTIFICATION" \
  | jq 'map({subject, source, senderPersonId})'
```

There is no push delivery — nothing calls your app. Poll `unread-count` per
active user and fetch the inbox when it changes.

## Rules that will eventually surprise you

- **Sent messages block person deletion.** A person who has sent or received
  messages can't be deleted — and sent messages are permanent. For tenant
  teardown, `DELETE /tenants/{tenantId}/communications` (no acting person —
  an admin action) purges every communication in the tenant.
- **Drafts belong to their sender**: only the sender can edit, send, or
  delete one, and only the sender's `/drafts` lists it. They are not fully
  invisible, though — a draft already names its recipients, and an addressed
  recipient can fetch it by id (a draft reply also shows up in its thread).
  Don't put anything in a draft you wouldn't send.
- **The two base types are fixed**: "Base Message" and "Base Notification"
  are seeded per tenant and can't be created, modified, or deleted. Custom
  communication types (extra attributes per message) pick their `channel` at
  creation — `MESSAGE` if omitted — and keep it forever.

---

## Next

- [Invoice from events](/docs/invoice-from-events.html) — a flow that fires
  two of the three system triggers.
- [Core concepts](/docs/concepts.html#communications) — the model behind
  channels, threads, and read state.
