Message Response Tracking and Routing
A conversation can move between teams while the patient keeps replying in the same thread. Use a Task to track who owes the next response, and Communication to retain what everyone said. Reassigning the work then preserves the conversation and its history.
Use a named owner when one person is responsible, a group owner for team accountability, or an unowned pool when eligible staff claim work. The examples below use an unowned role pool.
Communication + Task Relationship
flowchart TD
Task[Respond to the patient's question] -->|focus| Comm[Communication thread header]
Task -->|for| Patient[Patient]
Comm -->|subject| Patient
Task.owner identifies the accountable assignee. The thread's Communication.recipient retains all conversation participants, including its creator. When a new assignee joins the conversation, add that participant through your membership workflow; preserve the other participants. Returning work to a pool changes the Task's routing fields while retaining the conversation's membership. See the Messaging Data Model.
Task Element Reference
| Element | What it does |
|---|---|
focus | References the Communication thread header requiring a response |
for | Identifies the patient the work concerns |
owner | Identifies the accountable person, practitioner role, or group; unset for an unowned pool |
performerType | Describes the required role, evaluated through the application's eligibility rules |
requester | Identifies who requested the work |
status | Tracks the work lifecycle, such as ready, in-progress, and completed |
businessStatus | Records local stages such as waiting for clinical review |
priority | Uses routine, urgent, asap, or stat |
output | References the response Communication that resolved the obligation, with a type describing that output |
Create a Task for a Thread
Create a Task when a message needs an accountable response. Informational messages can stay in the conversation without creating work. For repeated events, conditionally create the Task using a stable identifier for the thread and response obligation.
{
"resourceType": "Task",
"identifier": [{
"system": "https://example.org/message-work",
"value": "thread-001-response-001"
}],
"status": "ready",
"intent": "order",
"code": { "text": "Respond to patient question" },
"focus": { "reference": "Communication/thread-001" },
"for": { "reference": "Patient/example" },
"performerType": [{ "text": "Clinical reviewer" }]
}
The role label is illustrative. Use the coded role and matching query when building a searchable pool. Set an owner when assigning directly, retaining performerType when the role requirement remains relevant.
Claim a Task from the Pool
Query for the pool's work type, role, open status, and owner:missing=true. A role filter alone also returns work that someone has already claimed. A group-owned queue instead searches for that group's owner reference.
Read the current Task, check the claimant's eligibility, and update owner using If-Match for the version read. If the version changed, reload the Task and let the user review the current assignment. Follow Build a Queue and Let Staff Claim Work for the shared claiming pattern.
After checking eligibility in your controlled assignment path, pass the Task version the user reviewed to this function. A conflict returns undefined; reload the queue and ask the user to review the current assignment before another attempt.
import type { MedplumClient } from '@medplum/core';
import { getStatus, normalizeOperationOutcome } from '@medplum/core';
import type { Annotation, Bundle, CodeableConcept, Communication, Task } from '@medplum/fhirtypes';
function versionHeader(resource: Task | Communication): string {
if (!resource.id || !resource.meta?.versionId) {
throw new Error('Read the saved resource and its version before updating it');
}
return `W/"${resource.meta.versionId}"`;
}
export async function claimTask(
medplum: MedplumClient,
current: Task,
owner: NonNullable<Task['owner']>
): Promise<Task | undefined> {
const ifMatch = versionHeader(current);
if (current.owner || current.status !== 'ready') {
throw new Error('This Task is no longer available in the ready, unowned pool');
}
try {
return await medplum.updateResource({ ...current, owner }, { headers: { 'If-Match': ifMatch } });
} catch (error) {
if (getStatus(normalizeOperationOutcome(error)) === 412) {
// Let the caller reload and show the current assignment. Do not retry the write.
return undefined;
}
throw error;
}
}Claiming establishes ownership. Set in-progress when the response work begins. Workflows that explicitly request and acknowledge a handoff can use requested and accepted as described in Handoffs.
Reroute to a Different Provider
For the same response obligation, update the existing Task's owner with a version check. Record why it moved so the next person can pick up the conversation. If that person is joining the thread, update the participant list to include them while preserving the remaining members.
If membership may change independently of assignment, first add the eligible participant with a version check, then update the Task's owner with its own version check. If the second write fails, keep the Task with its current owner and surface the incomplete transfer for reconciliation. This pattern is appropriate only when the participant may remain in the thread even if the transfer fails. Membership and access must still follow AccessPolicy.
If your business rule requires both changes to commit together, independent writes or a batch of upserts are insufficient. Atomic transaction Bundles are currently unavailable on hosted Medplum. The example below illustrates this stricter requirement only for a deployment with atomic processing enabled; it is not a hosted implementation. See availability and limits.
This example also adds the assignee to the thread. Pass the Practitioner who will participate in the conversation, even if a PractitionerRole owns the Task. Check eligibility and permission to join before calling it. Confirm atomic processing is enabled before using this example; the transaction request type alone does not provide that guarantee. On a version conflict, reload both records and review the proposed transfer; retrying must preserve any intervening membership changes.
// Requires atomic transaction processing, which is unavailable on hosted Medplum.
// Use only when Task ownership and thread membership must commit together.
export async function rerouteToProvider(
medplum: MedplumClient,
current: Task,
thread: Communication,
owner: NonNullable<Task['owner']>,
participant: NonNullable<Communication['recipient']>[number],
note: Annotation
): Promise<Bundle> {
const taskVersion = versionHeader(current);
const threadVersion = versionHeader(thread);
// Thread headers group messages and carry no payload of their own.
if (current.focus?.reference !== `Communication/${thread.id}` || thread.partOf?.length || thread.payload?.length) {
throw new Error('Expected the thread header referenced by this Task');
}
if (!['ready', 'accepted', 'in-progress', 'on-hold'].includes(current.status)) {
throw new Error('Only open response work can be rerouted');
}
if (!participant.reference) {
throw new Error('The joining participant needs a resource reference');
}
const recipients = [...(thread.recipient ?? [])];
// Retain all participants, including the creator, and avoid adding duplicates.
for (const member of [thread.sender, participant]) {
if (member?.reference && !recipients.some((entry) => entry.reference === member.reference)) {
recipients.push(member);
}
}
return medplum.executeBatch({
resourceType: 'Bundle',
type: 'transaction',
entry: [
{
resource: { ...current, owner, note: [...(current.note ?? []), note] },
request: { method: 'PUT', url: `Task/${current.id}`, ifMatch: taskVersion },
},
{
resource: { ...thread, recipient: recipients },
request: { method: 'PUT', url: `Communication/${thread.id}`, ifMatch: threadVersion },
},
],
});
}Reroute to a Provider Pool
Clear Task.owner, set the required performerType, and return the work to the pool's actionable status. Retain its focus, patient context, and history. Keep the thread's participant list intact; the pool is a routing destination for work.
The helper below updates only the Task. It preserves earlier notes and leaves the conversation's participants intact. Use the same version-checking helper shown above; let a conflict reach the caller for review.
export async function rerouteToPool(
medplum: MedplumClient,
current: Task,
role: CodeableConcept,
note: Annotation
): Promise<Task> {
const ifMatch = versionHeader(current);
if (!['ready', 'accepted', 'in-progress', 'on-hold'].includes(current.status)) {
throw new Error('Only open response work can return to the pool');
}
const updated: Task = {
...current,
status: 'ready',
performerType: [role],
note: [...(current.note ?? []), note],
};
delete updated.owner;
// This pool treats the transfer as ready for a new assignee to resume.
delete updated.statusReason;
return medplum.updateResource(updated, { headers: { 'If-Match': ifMatch } });
}Configure eligibility through Teams and Delegation and visibility through the assignment and access rules. This includes access to the thread and individual messages the assignee needs to read.
Tracking Reroute History
Resource history retains earlier Task versions, including previous owners. Use an authored note for the reason, or Provenance when the workflow needs structured attribution. See Resource History.
Free Text Reasons
Append an Annotation to Task.note with authorReference or authorString, time, and a concise reason. Preserve earlier notes. For example, "Transferred to the clinical review pool because the patient asked about their treatment plan" gives the next assignee useful context.
Structured Reason Codes
Create Provenance targeting the relevant Task version, recording the actor and a verified local or standard reason code. Use occurredDateTime for the effective transfer time and recorded for when it was recorded. See Operational Reporting for deriving owner and stage intervals.
Reroute Visibility
Decide whether the previous owner should retain access through AccessPolicy. Their access to a Task and to the conversation are separate policy decisions.
Update Task in Place
Keep one Task when the obligation stays the same. Updating its owner preserves one work history, and access policies can permit the former owner to retain visibility where required.
Create a New Task for the New Owner
Create a separate Task when the next person has a distinct obligation with its own completion criteria, such as a clinical review alongside a coordinator's patient response. Both can focus on the same thread. Choose this pattern because the work is separate, rather than to duplicate an item for visibility.
Complete the Response and Notify the Team
Reference the resolving message in Task.output with a type and valueReference. Complete the Task when the response satisfies the workflow's criteria. If the reply creates another obligation, give that work an owner and its own completion criteria.
Use assignee notifications for live queue updates and outbound alerts. Use Handoffs and Escalation when the response becomes overdue.