Messaging & Communications Decision Guide | Medplum

Section 1: Use Case & Participants

These questions establish the "who and why" before scoping any features. The answers should shape how you frame the rest of the conversation.

1.1 Who is messaging whom?

Why it matters: different participant types affect access control, thread structure, and whether a patient subject is always required on a thread.

1.2 What is the primary purpose of messaging in their product?

Why it matters: determines how much of the feature set is actually needed and whether async encounters/billing will come up.

1.3 Is this a new build or replacing something they have today?

Why it matters: existing systems often carry assumptions about data model or UX that will surface as requirements. Knowing this early prevents surprises.

Section 2: Feature Scoping

Go through each together. For each: Yes / No / Nice-to-have / Not sure.

# Feature Yes No Nice-to-have Not sure
1 Live / real-time updates (new messages appear without refresh)
2 Message response tracking and routing (assigning threads to providers or queues)
3 Read receipts and unread counts
4 File and image attachments
5 Message editing and drafts
6 Automated messaging (reminders, out-of-office, SLA escalations)
7 External channel delivery (SMS, email — beyond in-app)
8 Async encounters / billing

Section 3: Feature Deep Dives

Cover each feature flagged Yes or Nice-to-have. The goal is to land on a clear recommended approach by the end of each section.

3.1 Thread Structure (always covered)

Questions:

What the answers drive:

Situation Approach
Threads always tied to a patient Set subject on every thread header — enables filtering by patient across the inbox
Internal/admin threads needed subject is optional — don't assume it's always present in queries or UI
1:1 only Simpler participant model; simplifies read receipt implementation (see 3.4)
Group threads needed Recipient list on thread header must include all participants including the thread creator
Tags/categories needed, single dimension Use a single category entry on both the thread header and child messages
Tags/categories needed, multiple combinable dimensions Use multiple category entries (e.g. one for specialty, one for credential level) — more maintainable but queries may need to match several categories

3.2 Live Updates

Questions:

What the answers drive:

Situation Approach
Real-time updates needed WebSocket subscriptions on the thread — requires this to be scoped and enabled for the project
Polling is acceptable Simpler implementation; periodic re-fetch of thread messages
Notification only (e.g. badge, push) without live message rendering Can use subscription to trigger a notification without requiring in-place message updates

3.3 Routing & Assignment

Questions:

What the answers drive:

Situation Approach
Individual assignment only Set Task.owner to a specific provider; remove performerType
Pool/queue routing Set Task.performerType by role type; clear Task.owner; providers claim from pool
Both Use performerType for initial pool routing; set owner and clear performerType when claimed
Reassignment needed, previous owner loses access Update Task.owner in place; update Communication.recipient to match
Reassignment needed, previous owner retains visibility Create a new Task for the new owner; mark original as cancelled with a note pointing to the new Task
Audit trail needed (free text) Append to Task.note on each reroute with author and timestamp
Audit trail needed (structured/queryable) Create a Provenance resource on each reroute with a coded reason — queryable via Provenance?target=Task/{id}

3.4 Read Receipts & Unread State

Questions:

What the answers drive:

Situation Approach
1:1 threads only Option A: mark message status = completed when read; query unread via status filter. Simplest model.
Group threads, no API unread query needed Option B: store per-participant read state as an extension on the thread header. Shows unread dot in UI; not searchable via API.
Group threads, API unread query needed Option C: create a read-receipt Task per recipient per message; complete it when read. Supports badge counts and unread queries. Most complex.

3.5 Attachments

Questions:

What the answers drive:

Situation Approach
Files are just downloads within a message Store as contentAttachment on the message payload — simple, no separate resource needed
Attachments need to be clinical documents (searchable, referenceable by patient/type) Create a DocumentReference and attach via contentReference on the payload — file becomes a first-class searchable clinical document. Note: this is the default behavior of Medplum's React ThreadChat component.
Attachments are references to existing clinical resources (e.g. a lab result) Use contentReference pointing at the relevant FHIR resource (e.g. DiagnosticReport)

3.6 Editing & Drafts

Questions:

What the answers drive:

Situation Approach
Sent messages cannot be edited No special handling needed
Sent messages can be corrected; edits need to be searchable Retract-and-correct: mark original status = entered-in-error, create new message with corrected content linked via inResponseTo and tagged with a correction category. Corrections are discoverable via status and category filters.
Sent messages can be corrected; searchability of edits not required In-place payload update via patchResource. Full version history still exists via _history but is not searchable.
Drafts only needed per browser/device Store in browser localStorage keyed by thread ID — no server resources needed, but lost if user clears storage or switches devices
Drafts need to persist across devices/sessions Store as Communication with status = preparation; update as user types (debounced); promote to in-progress with a sent timestamp when sent. Filter drafts out of thread message queries with status:not=preparation.

3.7 Automations

Questions:

What the answers drive:

Situation Approach
Event-triggered automation (e.g. send message when lab result arrives) Bot triggered by a Subscription on the relevant resource type
Recurring automation (e.g. flag threads with no response after N days) Cron-scheduled Bot that scans for stale threads and creates reminder Tasks
Out-of-office / availability-based rerouting Bot triggered on new message checks provider Schedule; reroutes Task to pool if provider has no available slots
SLA reporting needed Task data (authoredOn, status change timestamps, owner, priority) should be exported to an analytics platform — FHIR search is not designed for aggregate SLA metrics

3.8 External Channels

Questions:

What the answers drive:

Situation Approach
Outbound notifications only App creates the Communication, then calls executeBot directly to send via the external channel — not Subscription-triggered, so errors surface synchronously
Inbound replies needed Inbound webhook Bot receives the payload, identifies the patient, matches or creates a thread, creates a child Communication
External conversation ID available (e.g. Twilio conversation SID) Upsert thread header by that identifier on first message; use it as conditional partOf reference on all inbound messages — most reliable thread matching strategy
No external conversation ID Match thread by patient phone/email and open thread status; ambiguous cases need a defined fallback strategy
Round-trip (provider replies in-app, patient receives via SMS/email) Store channel on thread header as medium; app creates the Communication then calls executeBot to dispatch outbound — Bot reads medium to route to the right channel
Webhook retry / duplicate messages likely Use createResourceIfNoneExist with the provider's message ID as an identifier to prevent duplicate inbound Communications

3.9 Async Encounters / Billing

Questions:

What the answers drive:

Situation Approach
No billing intent No Encounter resource needed; thread stands alone
Billing needed, one patient per session Create an Encounter per session; link to the thread header via Communication.encounter
One thread = one session Straightforward 1:1 mapping of thread to Encounter
Rolling interaction model (e.g. continuous text thread, grouped by day) One Encounter spans multiple threads or a time window; multiple thread headers link to the same Encounter
Multiple patients can be in one session Create a parent session Encounter + one child Encounter per patient via Encounter.partOf; clinical details live on child Encounters
Clinical documentation needed for billing Populate Encounter.participant, Encounter.reasonCode, and Encounter.serviceType per patient Encounter

Related Integrations

The external messaging channels Medplum integrates with first-party — both deliver through FHIR Communication resources, so they slot into the thread model above: