Skip to main content

Message Editing and Drafts

This page covers two workflows: correcting a message that has already been sent, and saving draft messages before sending. The editing section presents both a compliance-focused retract-and-correct pattern and a simpler in-place update alternative. The drafts section covers browser-only and cross-device persistence strategies.

Message Editing and Correction

The recommended approach for healthcare messaging is the "retract and correct" pattern: mark the original as an error, then create a new Communication with the corrected content. This preserves an explicit audit trail, which is important for clinical and compliance scenarios.

Alternative for less regulated contexts

If your use case does not require explicit error marking (for example, internal team chat), you can update the Communication payload in place using patchResource. FHIR version history (meta.versionId) still preserves every previous version, so the audit trail exists — it is just not surfaced via status. Choose the approach that matches your compliance requirements.

The key difference is searchability: retract-and-correct makes edits discoverable via search (Communication?status=entered-in-error finds retracted messages, Communication?category=correction finds corrections). In-place updates make edit history only accessible via _history, which is not searchable. If you need to answer "show me all edited messages" without walking version history, use retract-and-correct.

Retract the Original Message

Mark the original message as entered-in-error:

await medplum.patchResource('Communication', 'original-message-id', [
{ op: 'replace', path: '/status', value: 'entered-in-error' },
]);

Create the Corrected Message

Create a new Communication with the corrected content. Use inResponseTo to link it to the original, and tag it with a correction category. Carry forward topic, subject, and recipient from the original message so the correction is discoverable via the same searches:

const originalSender = { reference: 'Practitioner/example-sender', display: 'Example Sender' };
const correctedMessage = await medplum.createResource({
resourceType: 'Communication',
status: 'in-progress',
partOf: [{ reference: `Communication/${threadHeader.id}` }],
topic: threadHeader.topic,
subject: threadHeader.subject,
recipient: [{ reference: 'Practitioner/doctor-gregory-house', display: 'Dr. Gregory House' }],
inResponseTo: [{ reference: 'Communication/original-message-id' }],
sender: originalSender,
payload: [{ contentString: 'Updated: The appointment is at 3 PM, not 2 PM.' }],
sent: new Date().toISOString(),
category: [
{
coding: [
{
system: 'https://medplum.com/CodeSystem/communication-category',
code: 'correction',
display: 'Correction',
},
],
},
],
});
console.log(correctedMessage);

The original message content is always preserved in the resource's version history (meta.versionId), accessible via the Medplum App's History tab.

Displaying Corrections

ApproachHow it works
Hide + replaceFilter out entered-in-error messages. Show the correction in the original's position (use inResponseTo to find the link).
Show with indicatorDisplay the original with a strikethrough or "this message was edited" label. Show the correction below it.
caution

entered-in-error messages still appear in search results unless you explicitly filter them out with status:not=entered-in-error in your message queries.

Draft Messages

Draft messages let users compose a message and return to it later before sending.

Single-Device Drafts (Browser Storage)

For the simplest case, save draft text to browser storage keyed by thread ID:

const draftStorageKey = `draft-${threadHeader.id}`;
const composedText = 'User composed text';
localStorage.setItem(draftStorageKey, composedText);
const restoredDraft = localStorage.getItem(draftStorageKey);
if (restoredDraft) {
console.log(restoredDraft);
}
localStorage.removeItem(draftStorageKey);

In your UI, bind the restored string to your composer state (for example, a React useState setter) instead of logging it.

This is fast and requires no server round-trips, but the draft is lost if the user switches devices or clears browser storage.

Server-side rendering

If your app uses server-side rendering (for example, Next.js), guard localStorage access behind a typeof window !== 'undefined' check to avoid errors during SSR.

Cross-Device Drafts (Server-Side)

For drafts that persist across devices, use Communication.status = "preparation":

const draftBody = 'Draft body';
const serverDraft = await medplum.createResource({
resourceType: 'Communication',
status: 'preparation',
partOf: [{ reference: `Communication/${threadHeader.id}` }],
sender: { reference: `Practitioner/${currentUser.id}` },
payload: [{ contentString: draftBody }],
});
console.log(serverDraft);

Update the draft as the user types (debounce to avoid excessive requests):

const draftResourceId = 'example-draft-communication-id';
const revisedDraftText = 'Revised draft body';
await medplum.patchResource('Communication', draftResourceId, [
{ op: 'replace', path: '/payload', value: [{ contentString: revisedDraftText }] },
]);

When the user sends the message, promote the draft. At this point, also ensure topic, subject, and recipient are populated if they were not set when the draft was first created:

// Also patch in topic, subject, and recipient if they were not set when the draft was created
await medplum.patchResource('Communication', 'example-draft-communication-id', [
{ op: 'replace', path: '/status', value: 'in-progress' },
{ op: 'add', path: '/sent', value: new Date().toISOString() },
{ op: 'add', path: '/topic', value: threadHeader.topic },
{ op: 'add', path: '/subject', value: threadHeader.subject },
{ op: 'add', path: '/recipient', value: [{ reference: 'Practitioner/doctor-gregory-house' }] },
]);
Draft attachments

Draft Communication resources support the same payload types as sent messages, including contentAttachment and contentReference. If your composer allows file uploads before send, add the attachment to the draft's payload array alongside or instead of contentString.

Loading Drafts

Query for the current user's drafts across all threads:

// Set `sender` on draft Communications at creation time so they can be queried per user
await medplum.searchResources('Communication', {
sender: `Practitioner/${currentUser.id}`,
status: 'preparation',
});
caution

Other users should never see your drafts. When loading messages for a thread, filter with status:not=preparation to exclude all drafts. Only load drafts via a sender-scoped query for the current user.

Draft cleanup

Server-side drafts can accumulate over time if users start composing but never send. Set up a cron-triggered Bot to purge preparation Communication resources older than a threshold (for example, 30 days). For large numbers of stale drafts, batch the deletes using medplum.executeBatch() with a Transaction Bundle for better performance.

const staleDraftCutoff = new Date();
staleDraftCutoff.setDate(staleDraftCutoff.getDate() - 30);
const staleDrafts = await medplum.searchResources('Communication', {
status: 'preparation',
_lastUpdated: `lt${staleDraftCutoff.toISOString()}`,
});
for (const stale of staleDrafts) {
await medplum.deleteResource('Communication', stale.id);
}
console.log(staleDrafts.length);

See Cron Jobs for Bots for scheduling setup.

In Your UI

For corrections, choose one of the two display approaches in the table above. If you use "hide + replace," query with status:not=entered-in-error and use the correction's inResponseTo reference to position it where the original appeared. If you use "show with indicator," render the retracted message with a visual label (for example strikethrough text or an "edited" badge) and display the correction inline below it.

For drafts, show an auto-saving indicator in the composer when using server-side drafts so users know their work is persisted. In the thread list, consider showing a "draft" badge next to threads where the current user has an unsent preparation Communication so they can resume composing.

See Also