SOAP Draft
Status:
stableCategory:trinityassistAdded:2026-05-12Last reviewed:2026-05-26
Summary
Generate a SOAP-format clinical note draft from one clinician-patient encounter. Returns four narrative sections (Subjective / Objective / Assessment / Plan) plus a structured extraction (vitals, assessments, plan items, ICD-10 candidates, chief complaint, follow-up).
The endpoint supports three call modes that drive both the model tier and the prompt framing:
on_demand(default): the legacy "draft button" path. Ship the full diarized transcript assessionsand get a draft on the engine's default tier. Use this when a clinician asks for a draft once, after the visit.refresh: periodic mid-visit refresh. Ship a pre-condensedtrimmed_transcriptproduced by/api/encounters/transcript/trim;sessionsmay be empty. Runs on the Haiku tier so a 5-minute cadence stays cost-bounded.final: end-of-call wrap-up. Same input shape asrefreshbut runs on Sonnet for the richest structured extraction.
This is a per-encounter TrinityAssist clinical surface. The consumer
renders the returned narrative + extraction in editable review surfaces;
the clinician edits and signs off on its side. At case close, the
consumer can generate typed reflection questions via
/api/case_reflection/questions and
forward the signed answers via
/api/case_reflection/ingest.
Endpoint
| Field | Value |
|---|---|
| Method | POST |
| Path | /api/encounters/soap/draft |
| Full URL | https://engine.tripod-health.com/api/encounters/soap/draft |
| Authentication | API key (required) |
| Request body | application/json |
| Response body | application/json |
| Idempotent | No (each call may return a slightly different draft) |
| Rate-limited | Yes |
Generated text passes the engine's outbound de-identification scan
before returning; identifier-shaped output fails with
502 UpstreamError rather than scrubbing silently.
Authentication
Standard API-key. Set apikey: $ENGINE_API_KEY on the request. See
authentication.md.
Request
Headers
| Header | Required | Notes |
|---|---|---|
apikey | Yes | Your engine API key. |
Content-Type | Yes | application/json. |
X-Request-ID | No | Echoed back; lets you correlate logs. |
Body
{
"sessions": [
{
"session_id": "00000000-0000-0000-0000-000000000001",
"started_at": "2026-05-12T14:02:00.000Z",
"segments": [
{
"speaker": "provider",
"speaker_label": "Provider",
"text": "Hello, can you describe what brings you in today?",
"started_at_ms": 0,
"ended_at_ms": 3500
},
{
"speaker": "patient",
"speaker_label": "Patient",
"text": "I've had lower-back pain for about a week...",
"started_at_ms": 3500,
"ended_at_ms": 14000
}
]
}
],
"encounter_meta": {
"visit_type": "initial",
"encounter_type": "in_person"
}
}
| Field | Type | Always present | Description |
|---|---|---|---|
sessions | array | Yes | Diarized transcript sessions ordered chronologically by each session's first segment. Required field. May be an empty array when trimmed_transcript carries the content (refresh / final modes). A re-Started visit appears as session 2. Max 32 sessions. |
sessions[].session_id | string | Yes | Consumer-supplied opaque session UUID. Treated as opaque by the engine. Max 64 chars. |
sessions[].started_at | string | Yes | ISO 8601 timestamp of the session's first segment. |
sessions[].segments | array | Yes | Non-empty. Diarized transcript turns in chronological order. Max 4,096 segments per session. |
sessions[].segments[].speaker | enum | Yes | One of provider, patient, other, unknown. |
sessions[].segments[].speaker_label | string | null | No | Optional human-readable label. The engine treats it as opaque. Max 128 chars. |
sessions[].segments[].text | string | Yes | Utterance text. Max 8,000 chars per segment. |
sessions[].segments[].started_at_ms | integer | Yes | Milliseconds since session start. |
sessions[].segments[].ended_at_ms | integer | Yes | Milliseconds since session start. |
encounter_meta | object | Yes | Encounter context for prompt shaping. |
encounter_meta.visit_type | enum | Yes | One of initial, follow_up, touchbase, amendment, final. |
encounter_meta.encounter_type | enum | Yes | One of in_person, telemedicine, telephonic. |
call_mode | enum | No | One of on_demand (default), refresh, or final. Drives the model tier the engine selects -- see Summary. |
trimmed_transcript | string | null | No | Pre-condensed clinical-content extract produced by /api/encounters/transcript/trim. When present and non-empty, the model prefers it over the raw sessions array. Max 64,000 chars. Required when sessions is empty. |
At least one of sessions (non-empty) or trimmed_transcript
(non-empty) MUST be provided. The engine rejects a request with both
empty as 400 ValidationError: "Either sessions or trimmed_transcript must be non-empty".
The body MUST NOT contain identifier-shaped values. The engine's
inbound de-identification middleware rejects any such payload with
400 DeIdentificationViolation. See
conventions.md.
Response
Success -- 200 OK
{
"subjective": "The patient presents with lower-back pain that began approximately one week ago after lifting boxes at work. Pain is worse in the morning and improves with movement. The patient rates pain intensity as six out of ten at baseline, with acute spikes to eight out of ten on forward bending. No associated numbness, tingling, or leg weakness reported; pain is localized to the lower back. The patient reports a similar episode approximately one year ago that resolved spontaneously within days without intervention.",
"objective": "Physical examination reveals mildly limited range of motion in lumbar flexion. Straight-leg raise test is negative bilaterally. No focal neurological deficits noted on lower-extremity examination. Vital signs stable: blood pressure 124/78 mmHg.",
"assessment": "Acute lower-back pain, likely mechanical in etiology, precipitated by work-related lifting. History of a prior similar episode one year ago suggests predisposition to recurrent strain injury. No evidence of radiculopathy or acute neurological compromise on current examination. Clinical presentation and exam findings are consistent with acute muscular strain or minor ligamentous injury without neurological involvement.",
"plan": "1. Initiate nonsteroidal anti-inflammatory medication (NSAID) for pain and inflammation management.\n2. Prescribe home mobility exercises and activity modification to promote recovery and prevent re-injury.\n3. Emphasize proper body mechanics with lifting and bending.\n4. Instruct patient to call immediately if pain worsens or if new leg symptoms (numbness, tingling, weakness) develop.\n5. Follow-up visit in two weeks to reassess response to conservative management; consider advanced imaging or specialist referral if symptoms do not substantially improve.",
"structured_extraction": {
"chief_complaint": "Lower-back pain",
"vitals": [
{ "name": "Blood Pressure", "value": "124/78", "unit": "mmHg", "ts": null }
],
"assessments": [
{ "summary": "Acute mechanical lower-back pain precipitated by occupational lifting; no radiculopathy on exam. Recurrent history (prior episode one year ago).", "confidence": null }
],
"plan_items": [
{ "summary": "Nonsteroidal anti-inflammatory medication for pain and inflammation", "kind": "medication" },
{ "summary": "Home mobility exercises and activity modification", "kind": "exercise" },
{ "summary": "Proper body mechanics with lifting and bending; call if pain worsens or leg symptoms develop", "kind": "education" },
{ "summary": "Return visit in two weeks; if no substantial improvement, consider advanced imaging or specialist referral", "kind": "follow_up" }
],
"follow_up": "Return visit in two weeks to reassess pain response to conservative management. Patients should call immediately if pain worsens or new neurological symptoms develop.",
"icd10_candidates": [
{ "code": "M54.5", "label": "Low back pain", "confidence": null }
]
},
"model_tier": "default",
"request_id": "req-xyz",
"timestamp": "2026-05-12T14:23:00.000Z"
}
The example above is a real production response generated from a synthetic two-session transcript covering one week of work-related lower-back pain, called with call_mode: "on_demand" (default). The narrative fields and the structured-extraction values are produced in a single LLM call.
| Field | Type | Always present | Description |
|---|---|---|---|
subjective | string | Yes | Patient's reported history, symptoms, concerns, in clinical voice. |
objective | string | Yes | Observed findings during the visit (vitals, physical exam, prior-result review). |
assessment | string | Yes | Clinical judgment integrating Subjective + Objective. |
plan | string | Yes | Next steps -- orders, prescriptions, referrals, education, follow-up. |
structured_extraction | object | Yes | Structured fields derived from the transcript. Inner arrays may be empty. |
structured_extraction.chief_complaint | string | null | No | One-line presenting complaint. |
structured_extraction.vitals | array | Yes | Vital-sign readings. Up to 32 entries. |
structured_extraction.vitals[].name | string | Yes | Vital name (e.g. "Blood pressure"). |
structured_extraction.vitals[].value | string | Yes | Numeric or qualitative value. |
structured_extraction.vitals[].unit | string | null | No | Unit of measure. |
structured_extraction.vitals[].ts | string | null | No | Timestamp if recorded mid-encounter. |
structured_extraction.assessments | array | Yes | Structured clinical assessments. Up to 32 entries. |
structured_extraction.assessments[].summary | string | Yes | Assessment text. |
structured_extraction.assessments[].confidence | number | null | No | [0.0, 1.0]. |
structured_extraction.plan_items | array | Yes | Discrete plan items. Up to 32 entries. |
structured_extraction.plan_items[].summary | string | Yes | Plan-item text. |
structured_extraction.plan_items[].kind | string | null | No | Optional item-type hint (e.g. prescription, referral, education). |
structured_extraction.follow_up | string | null | No | Free-text follow-up plan summary. |
structured_extraction.icd10_candidates | array | Yes | Suggested ICD-10 codes. Up to 32 entries. |
structured_extraction.icd10_candidates[].code | string | Yes | ICD-10 code. |
structured_extraction.icd10_candidates[].label | string | Yes | Human-readable label. |
structured_extraction.icd10_candidates[].confidence | number | null | No | [0.0, 1.0]. |
model_tier | string | Yes | Which model tier the engine selected for this call. "haiku" when call_mode was refresh, "sonnet" when call_mode was final, "default" for on_demand. Returned so callers can confirm tier selection and reason about latency / quality. |
request_id | string | null | No | Echo of the inbound X-Request-ID. |
timestamp | string | Yes | ISO 8601, UTC. |
The four narrative fields are guaranteed non-empty -- if the
transcript is too thin to support a section, the engine writes the
shortest honest sentence (e.g. "No objective findings documented
during this visit.") rather than fabricating content. Empty inner
arrays in structured_extraction are normal when the transcript is
silent on that dimension.
Status codes
| Code | Meaning | When |
|---|---|---|
200 | OK | Draft generated. |
400 | ValidationError | Body shape, enum violation, invalid call_mode, or both sessions empty AND trimmed_transcript absent. |
400 | DeIdentificationViolation | Body carries an identifier-shaped value. |
401 | Unauthenticated | Missing or invalid API key. |
413 | PayloadTooLarge | Body exceeds 512 KB. |
415 | UnsupportedMediaType | Content-Type is not application/json. |
429 | RateLimited | Per-consumer rate limit exceeded. |
500 | InternalError | Unexpected failure. |
502 | UpstreamError | Upstream model call failed, or generated text failed the outbound de-id pass. |
503 | ServiceUnavailable | Service temporarily unhealthy. |
504 | UpstreamTimeout | Upstream model call exceeded the engine's draft-generation budget. |
Examples
curl
curl -sS -X POST "https://engine.tripod-health.com/api/encounters/soap/draft" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessions": [
{
"session_id": "00000000-0000-0000-0000-000000000001",
"started_at": "2026-05-12T14:02:00.000Z",
"segments": [
{
"speaker": "provider",
"speaker_label": "Provider",
"text": "Hello, can you describe what brings you in today?",
"started_at_ms": 0,
"ended_at_ms": 3500
},
{
"speaker": "patient",
"speaker_label": "Patient",
"text": "I have had lower-back pain for about a week.",
"started_at_ms": 3500,
"ended_at_ms": 9000
}
]
}
],
"encounter_meta": {
"visit_type": "initial",
"encounter_type": "in_person"
}
}'
Python (requests)
# Uses the call() helper from quickstart.md.
result = call("POST", "/api/encounters/soap/draft", {
"sessions": [
{
"session_id": "00000000-0000-0000-0000-000000000001",
"started_at": "2026-05-12T14:02:00.000Z",
"segments": [
{
"speaker": "provider",
"speaker_label": "Provider",
"text": "Hello, can you describe what brings you in today?",
"started_at_ms": 0,
"ended_at_ms": 3500,
},
{
"speaker": "patient",
"speaker_label": "Patient",
"text": "I have had lower-back pain for about a week.",
"started_at_ms": 3500,
"ended_at_ms": 9000,
},
],
}
],
"encounter_meta": {
"visit_type": "initial",
"encounter_type": "in_person",
},
})
print("--- Subjective ---")
print(result["subjective"])
print("--- Plan items ---")
for item in result["structured_extraction"]["plan_items"]:
print("-", item["summary"])
Node.js (native fetch)
// Uses the call() helper from quickstart.md.
const result = await call("POST", "/api/encounters/soap/draft", {
sessions: [
{
session_id: "00000000-0000-0000-0000-000000000001",
started_at: "2026-05-12T14:02:00.000Z",
segments: [
{
speaker: "provider",
speaker_label: "Provider",
text: "Hello, can you describe what brings you in today?",
started_at_ms: 0,
ended_at_ms: 3500,
},
{
speaker: "patient",
speaker_label: "Patient",
text: "I have had lower-back pain for about a week.",
started_at_ms: 3500,
ended_at_ms: 9000,
},
],
},
],
encounter_meta: {
visit_type: "initial",
encounter_type: "in_person",
},
});
console.log("Subjective:", result.subjective);
for (const item of result.structured_extraction.plan_items) {
console.log("-", item.summary);
}
Refresh mode with a trimmed transcript (mid-visit periodic draft)
Use call_mode: "refresh" and pass the trimmed_text returned by
/api/encounters/transcript/trim
as trimmed_transcript. sessions may be empty -- the engine
reads the trimmed text as its primary source. This path runs on
Haiku.
curl -sS -X POST "https://engine.tripod-health.com/api/encounters/soap/draft" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessions": [],
"encounter_meta": { "visit_type": "initial", "encounter_type": "in_person" },
"call_mode": "refresh",
"trimmed_transcript": "Chief Complaint: Right lower back pain for three weeks, onset after lifting. History of Present Illness: ..."
}'
The response shape is unchanged; the model_tier field will be
"haiku".
Final mode with a trimmed transcript (end-of-call wrap-up)
Same shape as refresh but use call_mode: "final". Runs on
Sonnet for richer structured extraction (more ICD-10 candidates,
typed plan_items[].kind, longer narrative).
curl -sS -X POST "https://engine.tripod-health.com/api/encounters/soap/draft" \
-H "apikey: $ENGINE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sessions": [],
"encounter_meta": { "visit_type": "initial", "encounter_type": "in_person" },
"call_mode": "final",
"trimmed_transcript": "Chief Complaint: Right lower back pain for three weeks, onset after lifting. ..."
}'
Expect higher latency on final (Sonnet) than refresh (Haiku) or
on_demand (default tier).
Intended use
- Render the four narrative fields as editable text areas for clinician review and edit before save.
- Render the structured extraction in a sidebar or chip cluster for quick reference; let the clinician modify or remove items before saving the encounter record.
- Re-fire the endpoint if the clinician wants a fresh draft after capturing more transcript content (e.g. a re-Started visit added a second session).
- Wire the periodic mid-visit refresh by calling
/api/encounters/transcript/trimevery few minutes and feeding itstrimmed_textback into this endpoint withcall_mode: "refresh". End the visit with onecall_mode: "final"call for the richer end-of-call draft.
Mistaken use
- Persisting the draft verbatim without clinician review. The output is a draft -- it is the clinician's responsibility to edit and attest before it becomes the clinical record.
- Treating ICD-10 candidates as billing codes. They are suggestions to speed coder review, not authoritative claims data.
- Sending raw patient identifiers in
session_idorspeaker_label. Both are opaque to the engine; use consumer-side surrogates and keep the consumer-internal mapping on your side. - Asking the endpoint to summarize material that is not in the transcript (lab results, prior visit notes, imaging reports). The prompt is constrained to the transcript; out-of-band material has to flow through a different surface.
De-identification contract
This endpoint enforces both directions of the engine's de-id contract:
- Inbound: the request body is walked for identifier-shaped strings
and blocked field names. Failures return
400 DeIdentificationViolation. See conventions.md. - Outbound: the four narrative fields plus every string the
structured extraction can carry are scanned for identifier shapes
(SSN, email, phone, ISO date, MRN-like patterns). A match fails the
call with
502 UpstreamError; the engine never silently scrubs.
Rate limits
Per-consumer limits are managed at the gateway. See rate-limits.md.
Related
POST /api/encounters/transcript/trim-- periodic condensation of a live-call transcript window. Its output is thetrimmed_transcriptyou pass back in forrefreshandfinal.POST /api/case_reflection/questions-- typed per-case reflection prompts.POST /api/case_reflection/ingest-- forward signed answers to the learning corpus.- conventions.md -- de-identification contract, request IDs, timestamp shape.
- errors.md -- error envelope and code list.
Change history
| Date | Change |
|---|---|
| 2026-05-12 | Initial publication. |
| 2026-05-26 | Added call_mode (on_demand / refresh / final) and trimmed_transcript request fields for the periodic-refresh + end-of-call wrap-up pattern. sessions may now be empty when trimmed_transcript carries the content. Response gains model_tier reporting which tier the engine selected. |