Care hand-offs API
The staff-only follow-up queue - list and detail, claim, contact attempt, resolve with a disposition, resume automation - with optimistic versioning and the messaging policy that routes it.
A care hand-off is a durable follow-up owned by a person. It is created when the loop cannot continue on its own for a patient, it moves queued to claimed to resolved, and while it stands, automated care SMS to that patient is withheld. The routes are under /api/v1/care-handoffs and are staff-session only: no API key, patient SDK token or patient account can claim ownership, record contact, or release a hold.
When a hand-off is created
A hand-off is opened with one of these reason values:
reason | What happened |
|---|---|
message_budget_exhausted | The patient's automated care SMS allowance for the window is spent |
message_policy_missing | No patient messaging policy is configured for the environment, so no send is permitted |
automation_paused | A protocol rule matched (a red flag on a reading or on inbound text) and automation is paused for the patient |
delivery_outcome_unknown | A send was attempted and the provider's outcome could not be established |
budget_exhausted, policy_missing | The same two conditions, recorded by an older caller |
There is at most one open hand-off per patient per environment. A second cause while one is open appends an automation_withheld event to the existing case, bumps its version so a person must refresh, and reopens it if it had been resolved with automation still paused. A new patient reply reopens a resolved case too; the prior disposition stays in the event log.
Each hand-off carries: state, reason, queue_label, routing_snapshot, assigned_to, claimed_at, due_at, escalation_due_at, escalated_at, resolved_at, resolved_by, disposition, resolution_note, automation_resumed_at, episode_id, version.
Read routes
Read scope on the key is not enough; the caller must be a signed-in staff member (admin, member or physician) of the organization. Responses carry Cache-Control: no-store.
GET /api/v1/care-handoffs
Query: status (queued, claimed, resolved), sort (created, the default, oldest first; or due, earliest first with unset last), xuser_id, assigned_to, project_id (membership by the project roster), overdue (true for unresolved cases past due_at), limit (1 to 100, default 50), offset.
Response: environment_id, items (the hand-off rows), total, and patient_names keyed by patient id.
GET /api/v1/care-handoffs/{id}
Query: events_before (a cursor for older events).
Response: environment_id, handoff, events (oldest first, up to 100, with next_event_cursor when more exist), patient (id, display_name), and recent_replies - the patient messages already attached to this case, never the patient's whole conversation.
Event types: automation_withheld, claimed, contact_attempt, resolved, automation_resumed, patient_reply_received, protocol_rule_routed, overdue_escalated. Each carries an evidence object of references (episode, session, message ids), never copies of patient text.
Write routes
Write scope on the key, plus a staff session. Every write takes expected_version, and every successful write increments version.
expected_version and the 409
The row's version is bumped by every state change, including ones a person did not make: fresh evidence, a new patient reply, an overdue escalation. A write whose expected_version does not equal the current version is refused:
HTTP 409 Conflict
{ "error": "Follow-up changed; refresh before acting" }Re-read the detail, look at what changed, and resubmit with the new version. The same 409 is returned when the action is not valid in the current state (claiming a resolved case, contacting a case you do not own).
POST /api/v1/care-handoffs/{id}/claim
Body: { "expected_version": 3 }. Moves queued to claimed and sets assigned_to to the caller. Eligibility follows the routing snapshot: before escalation, the primary staff list; after escalation, primary plus backup. If none of the routed staff are still members, anyone on staff may claim, so a queue cannot be orphaned by a membership change. An organization admin may reclaim a claimed case.
POST /api/v1/care-handoffs/{id}/contact-attempt
Body: expected_version, outcome (contacted or no_answer), note (required, up to 4000 characters), next_due_at (required when outcome is no_answer; must be in the future and within a year). Only the assignee may record contact. A next_due_at moves due_at and, before escalation, escalation_due_at. Returns 422 with a message when the body is incomplete.
POST /api/v1/care-handoffs/{id}/resolve
Body: expected_version, disposition, note (required), resume_automation (boolean). Dispositions:
disposition | Meaning |
|---|---|
follow_up_completed | The person reached the patient and the follow-up is done |
no_outreach_needed | Reviewed; no contact was required |
referred | Routed onward to another team or service |
Resolution alone does not release the automation hold. Only resume_automation: true here, or a later resume, sets automation_resumed_at; until then automated care SMS to the patient stays withheld and a new reply reopens the case. Resolving clears the attention contribution the hand-off raised.
POST /api/v1/care-handoffs/{id}/resume
Body: expected_version, note (required: why automation may resume). Valid only on a resolved case whose automation is still paused; the assignee or an admin may call it.
Routing, due times and escalation
Due and escalation times come from the environment's patient messaging policy at the moment the hand-off is created, frozen onto the row as routing_snapshot: due_at is now plus response_seconds; escalation_due_at is due_at plus escalation_seconds. A worker sweep (at most 100 cases per pass) marks unresolved cases past escalation_due_at as escalated, returns them to queued, clears the owner, and records overdue_escalated with "routing": "backup_queue". The sweep changes the in-product queue only; it sends no external notification and makes no claim of after-hours coverage.
The messaging policy routes
The hand-off queue and the per-patient message budget read one policy per organization and environment. There are no numeric defaults: a missing policy is itself a hand-off reason.
GET /api/v1/patient-messaging-policy
Read scope plus a staff session. Returns { "policy": ..., "environment_id": ... }; policy is null until one is saved.
PUT /api/v1/patient-messaging-policy
Admin scope plus an organization admin session. Body:
{
"max_messages": 8,
"window_seconds": 3600,
"routing": {
"queue_label": "Care team follow-up",
"primary_user_ids": ["<staff uuid>"],
"backup_user_ids": ["<staff uuid>"],
"response_seconds": 600,
"escalation_seconds": 120,
"coverage_note": "Weekday clinic hours; overnight cases wait for the morning huddle"
}
}Validation: max_messages positive; window_seconds, response_seconds and escalation_seconds between 1 and 31,536,000; both staff lists non-empty, distinct, at most 100 each, and every id a current staff member; queue_label 1 to 120 characters; coverage_note 1 to 1000 characters. Unknown fields are rejected. Durations are elapsed time, not a staffed-hours calendar; coverage_note is where a team writes what its hours are.
GET /api/v1/patient-messaging-policy/staff
Read scope plus a staff session. Lists the staff members (id, name) who may appear in the routing lists.
Where it appears
The dashboard's Review Queue page carries the hand-off queue beside agent runs and held messages. The settings page for patient messaging edits the policy above.
Symptom agent and the gate
What the SMS symptom agent may do, what it never does, the deterministic red-flag screen, the three gate outcomes and the hold, and the AI-identity disclosure.
Export
FHIR R4 bundles validated against US Core in CI, HL7 v2 ORU over MLLP, the Epic adapter proven against Epic's developer sandbox, and a generic FHIR R4 server target.