Real-Time SOAP Streaming
Real-Time SOAP Streaming
Stream structured clinical documentation as it is generated. Subjective, Objective, Assessment, and Plan sections arrive progressively via Server-Sent Events — your UI updates in real time as each section completes.
Quick Start
Get real-time SOAP notes in under 15 lines of JavaScript:
const response = await fetch('/v1/consultation/progressive-soap/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': 'YOUR_API_KEY', 'Accept': 'text/event-stream' }, body: JSON.stringify({ consultation_id: 'consult-abc-123', accumulated_text: 'Patient reports headache for 3 days, tension type, no nausea. Denies fever. BP 120/80. HR 72. Normal neurological exam.', stream: true })});
const reader = response.body.getReader();const decoder = new TextDecoder();let buffer = '';
while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop(); for (const block of blocks) { const evt = block.match(/^event:\s*(.+)$/m)?.[1]; const data = block.match(/^data:\s*(.+)$/m)?.[1]; if (evt && data) console.log(evt, JSON.parse(data)); }}Architecture
The streaming flow is designed for low latency and intelligent caching. Your client sends the full accumulated transcript, and DELPHOS decides the most efficient path to deliver structured SOAP sections.
Your Client DELPHOS SSE Events Live UI──────────── ──► ─────────────────── ──► ──────────────────── ──► ────────Send full Cache check + status → partial Real-timeaccumulated generation → soap (or error) sectiontext Smart 5-word delta updatesEndpoint Reference
POST /v1/consultation/progressive-soap/streamRequest Headers
| Header | Value | Required |
|---|---|---|
Content-Type | application/json | Yes |
x-api-key | Your API key | Yes |
Accept | text/event-stream | Recommended |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
consultation_id | string | Yes | External consultation identifier (1–255 chars) |
accumulated_text | string | No | Full transcript accumulated so far (max 100,000 chars). Always send the COMPLETE text each time, not just new chunks. Null is coerced to empty string. Default: "" |
previous_soap_hash | string | null | No | SHA-256 hash from the last response’s soap_hash field. Enables cache optimization. Default: null |
stream | boolean | No | If true (default), return SSE stream. If false, return standard JSON response. |
Example Request
{ "consultation_id": "consult-abc-123", "accumulated_text": "Paciente relata cefaleia há 3 dias, tipo tensional, sem náuseas. Nega febre. PA 120/80. FC 72. Exame neurológico normal.", "previous_soap_hash": "a1b2c3d4e5f67890abcdef1234567890abcdef1234567890abcdef1234567890", "stream": true}SSE Event Types
The stream emits four event types, always in order: status first, then zero
or more partial events, and finally either soap (success) or error.
status — Processing Status
Indicates whether DELPHOS is returning a cached result or generating new content.
event: statusdata: {"type": "cache_hit"}event: statusdata: {"type": "generating"}| Type | Meaning |
|---|---|
cache_hit | Previous result returned from cache — no processing needed |
generating | New SOAP content is being generated |
partial — Intermediate SOAP
Delivers partially-complete SOAP sections as they are generated. The
completeness object indicates which sections are finished. You may receive
multiple partial events as more sections complete.
event: partialdata: { "subjective": "Paciente relata cefaleia há 3 dias, tipo tensional...", "objective": "PA 120/80, FC 72, exame neurológico normal.", "assessment": "", "plan": "", "completeness": { "s": true, "o": true, "a": false, "p": false }}| Field | Type | Description |
|---|---|---|
subjective | string | Patient’s reported symptoms and history |
objective | string | Clinical findings and measurements |
assessment | string | Clinical assessment and diagnosis |
plan | string | Treatment plan and next steps |
completeness | object | { s, o, a, p } — boolean flags for each section |
soap — Final Complete SOAP
The definitive response with all four sections complete. Includes soap_hash
for cache optimization on subsequent requests.
event: soapdata: { "subjective": "Paciente relata cefaleia há 3 dias, tipo tensional, sem náuseas ou vômitos. Nega febre, alterações visuais ou trauma recente.", "objective": "PA 120/80 mmHg. FC 72 bpm. Exame neurológico normal. Sem sinais meníngeos.", "assessment": "Cefaleia tensional sem sinais de alarme. Sem indicação de investigação complementar neste momento.", "plan": "Orientação sobre higiene do sono e manejo de estresse. Analgésico simples (dipirona 500mg) SOS. Retorno se piora ou surgimento de novos sintomas.", "completeness": { "s": true, "o": true, "a": true, "p": true }, "soap_hash": "a3f8c2d91b4e5678901234567890abcdef1234567890abcdef1234567890ab12", "cached": false, "is_degraded": false}error — Error
Sent when processing fails. Includes a machine-readable code and a descriptive
message. When degraded is true, a best-effort partial SOAP may have been
emitted before the error.
event: errordata: { "code": "PROCESSING_TIMEOUT", "message": "Processing request timed out", "degraded": true}Integration Patterns
Choose the integration approach that best fits your stack. All patterns handle the full event lifecycle.
/** * Stream SOAP notes via POST + manual SSE parsing. * (EventSource only supports GET, so we use fetch + ReadableStream.) */async function streamSOAP(consultationId, text, prevHash, onEvent) { const response = await fetch('/v1/consultation/progressive-soap/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': 'YOUR_API_KEY', 'Accept': 'text/event-stream' }, body: JSON.stringify({ consultation_id: consultationId, accumulated_text: text, previous_soap_hash: prevHash, stream: true }) });
if (!response.ok) { throw new Error(`HTTP ${response.status}`); }
const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = '';
while (true) { const { done, value } = await reader.read(); if (done) break;
buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop(); // keep incomplete block
for (const block of blocks) { if (!block.trim()) continue; const evtLine = block.match(/^event:\s*(.+)$/m); const dataLine = block.match(/^data:\s*(.+)$/m); if (evtLine && dataLine) { onEvent(evtLine[1].trim(), JSON.parse(dataLine[1])); } } }}
// Usagelet lastHash = null;streamSOAP('consult-123', fullText, lastHash, (type, data) => { switch (type) { case 'status': console.log('Status:', data.type); // "cache_hit" or "generating" break; case 'partial': updateSOAPDisplay(data); // show sections as they arrive break; case 'soap': updateSOAPDisplay(data); lastHash = data.soap_hash; // save for next request break; case 'error': console.error(data.code, data.message); break; }});import { useState, useRef, useCallback } from 'react';
interface SOAPSections { subjective: string; objective: string; assessment: string; plan: string;}
interface SOAPCompleteness { s: boolean; o: boolean; a: boolean; p: boolean;}
interface SOAPStreamState { sections: SOAPSections; completeness: SOAPCompleteness; isStreaming: boolean; isDegraded: boolean; error: string | null; soapHash: string | null;}
function useSOAPStream(consultationId: string) { const [state, setState] = useState<SOAPStreamState>({ sections: { subjective: '', objective: '', assessment: '', plan: '' }, completeness: { s: false, o: false, a: false, p: false }, isStreaming: false, isDegraded: false, error: null, soapHash: null, });
const abortRef = useRef<AbortController | null>(null); const hashRef = useRef<string | null>(null);
const stream = useCallback(async (text: string) => { abortRef.current?.abort(); const controller = new AbortController(); abortRef.current = controller;
setState(prev => ({ ...prev, isStreaming: true, error: null }));
try { const res = await fetch('/v1/consultation/progressive-soap/stream', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': 'YOUR_API_KEY', 'Accept': 'text/event-stream', }, body: JSON.stringify({ consultation_id: consultationId, accumulated_text: text, previous_soap_hash: hashRef.current, stream: true, }), signal: controller.signal, });
if (!res.body) throw new Error('Response body is null'); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = '';
while (true) { const { done, value } = await reader.read(); if (done) break;
buffer += decoder.decode(value, { stream: true }); const blocks = buffer.split('\n\n'); buffer = blocks.pop()!; // split() always returns >= 1 element
for (const block of blocks) { if (!block.trim()) continue; const evtLine = block.match(/^event:\s*(.+)$/m); const dataLine = block.match(/^data:\s*(.+)$/m); if (!evtLine || !dataLine) continue;
const eventType = evtLine[1].trim(); const data = JSON.parse(dataLine[1]);
if (eventType === 'partial' || eventType === 'soap') { hashRef.current = data.soap_hash ?? hashRef.current; setState(prev => ({ ...prev, sections: { subjective: data.subjective, objective: data.objective, assessment: data.assessment, plan: data.plan, }, completeness: data.completeness, soapHash: data.soap_hash ?? prev.soapHash, isDegraded: data.is_degraded ?? false, })); } else if (eventType === 'error') { setState(prev => ({ ...prev, error: `${data.code}: ${data.message}`, isDegraded: data.degraded ?? false, })); } } } } catch (err: any) { if (err.name !== 'AbortError') { setState(prev => ({ ...prev, error: err.message })); } } finally { setState(prev => ({ ...prev, isStreaming: false })); } }, [consultationId]);
const cancel = useCallback(() => { abortRef.current?.abort(); }, []);
return { ...state, stream, cancel };}
// ── Usage Example ──────────────────────────────────────
function SOAPPanel({ consultationId }: { consultationId: string }) { const { sections, completeness, isStreaming, error, stream } = useSOAPStream(consultationId);
return ( <div> {(['subjective', 'objective', 'assessment', 'plan'] as const).map(key => ( <div key={key} className={completeness[key[0] as keyof typeof completeness] // key[0] maps 'subjective'->'s', etc. ? 'complete' : 'pending'}> <h3>{key.toUpperCase()}</h3> <p>{sections[key] || 'Waiting...'}</p> </div> ))} {isStreaming && <span>Generating...</span>} {error && <span className="error">{error}</span>} </div> );}import httpximport jsonfrom typing import AsyncGenerator
async def stream_soap( base_url: str, api_key: str, consultation_id: str, accumulated_text: str, previous_soap_hash: str | None = None,) -> AsyncGenerator[tuple[str, dict], None]: """Stream SOAP notes via SSE.
Yields (event_type, data) tuples as SOAP sections are generated progressively. """ url = f"{base_url}/v1/consultation/progressive-soap/stream" payload = { "consultation_id": consultation_id, "accumulated_text": accumulated_text, "previous_soap_hash": previous_soap_hash, "stream": True, } headers = { "Content-Type": "application/json", "x-api-key": api_key, "Accept": "text/event-stream", }
async with httpx.AsyncClient() as client: async with client.stream( "POST", url, json=payload, headers=headers, timeout=60.0 ) as response: response.raise_for_status() buffer = ""
async for chunk in response.aiter_text(): buffer += chunk
while "\n\n" in buffer: block, buffer = buffer.split("\n\n", 1) if not block.strip(): continue
event_type = None data_str = None for line in block.split("\n"): if line.startswith("event: "): event_type = line[7:].strip() elif line.startswith("data: "): data_str = line[6:].strip()
if event_type and data_str: yield event_type, json.loads(data_str)
# ── Usage ──────────────────────────────────────────────
import asyncio
async def main(): soap_hash = None async for event_type, data in stream_soap( base_url="https://your-instance.delphos.app", api_key="YOUR_API_KEY", consultation_id="consult-abc-123", accumulated_text="Patient reports headache for 3 days...", previous_soap_hash=soap_hash, ): if event_type == "status": print(f"Status: {data['type']}") elif event_type == "partial": complete = [k for k, v in data["completeness"].items() if v] print(f"Partial: {', '.join(complete)} complete") elif event_type == "soap": soap_hash = data.get("soap_hash") print(f"SOAP complete. Hash: {soap_hash[:16]}...") elif event_type == "error": print(f"Error: {data['code']} — {data['message']}")
asyncio.run(main())curl -X POST 'https://your-instance.delphos.app/v1/consultation/progressive-soap/stream' \ -H 'Content-Type: application/json' \ -H 'x-api-key: YOUR_API_KEY' \ -H 'Accept: text/event-stream' \ --no-buffer \ -d '{ "consultation_id": "consult-abc-123", "accumulated_text": "Paciente relata cefaleia há 3 dias, tipo tensional. PA 120/80. FC 72.", "previous_soap_hash": null, "stream": true }'Notes-Based Flow
When the physician types clinical notes directly, use a debounce pattern to avoid excessive requests. After each pause in typing, send the full accumulated text to DELPHOS.
Doctor Types → Debounce (1.5s) → Send Full Text → SOAP Builds Livelet debounceTimer = null;let lastHash = null;
textarea.addEventListener('input', () => { clearTimeout(debounceTimer); debounceTimer = setTimeout(() => { streamSOAP( consultationId, textarea.value, // always the FULL text lastHash, (type, data) => { if (type === 'partial' || type === 'soap') { updateSOAPDisplay(data); if (data.soap_hash) lastHash = data.soap_hash; } } ); }, 1500);});Audio-Based Flow
For voice-driven consultations, your application handles audio capture and speech-to-text conversion. Feed the growing transcript buffer into DELPHOS as it accumulates.
Audio Chunks → Your Transcription → Accumulated Text → DELPHOS SOAP(microphone) (speech-to-text) (growing buffer) (SSE stream)let transcriptBuffer = '';let soapHash = null;
// Called whenever your transcription service produces new textfunction onTranscriptChunk(newText) { transcriptBuffer += ' ' + newText;
streamSOAP( consultationId, transcriptBuffer, // always the COMPLETE buffer soapHash, (type, data) => { if (type === 'soap') { updateSOAPDisplay(data); soapHash = data.soap_hash; } else if (type === 'partial') { updateSOAPDisplay(data); } } );}Error Handling
When DELPHOS encounters an issue during generation, an error event is emitted.
Your application should handle each code and implement retry with backoff.
| Error Code | Description | Retry? |
|---|---|---|
PROCESSING_TIMEOUT | Processing exceeded the time limit. A previously streamed partial event may contain usable content. | Yes, with backoff |
PROCESSING_ERROR | Internal processing failure. | Yes, after short delay |
PARSE_ERROR | Output could not be parsed into SOAP sections. | Retry once — if persistent, input may need refinement |
INTERNAL_ERROR | Unexpected server error. | Yes, with backoff |
Retry with Exponential Backoff
async function streamWithRetry(consultationId, text, hash, onEvent, maxRetries = 3) { let attempt = 0;
while (attempt < maxRetries) { try { await streamSOAP(consultationId, text, hash, (type, data) => { if (type === 'error' && !['PARSE_ERROR', 'PROCESSING_TIMEOUT', 'PROCESSING_ERROR'].includes(data.code)) { throw new Error(data.code); } onEvent(type, data); }); return; // success } catch (err) { attempt++; if (attempt >= maxRetries) { // Fallback to non-streaming endpoint const res = await fetch('/v1/consultation/progressive-soap', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': 'YOUR_API_KEY' }, body: JSON.stringify({ consultation_id: consultationId, accumulated_text: text, previous_soap_hash: hash }) }); const data = await res.json(); onEvent('soap', data); return; } // Exponential backoff: 1s, 2s, 4s await new Promise(r => setTimeout(r, 1000 * 2 ** (attempt - 1))); } }}FAQ
Do I need to save SOAP notes to my database? No. DELPHOS handles all persistence automatically. Your application only needs to display the SOAP sections as they stream in. If you need historical access, query the DELPHOS consultation API.
How often should I send requests? Send requests on meaningful changes (e.g., after a typing pause). DELPHOS has a built-in 5-word delta threshold that automatically returns cached results when the text has not changed significantly.
What is previous_soap_hash?
A SHA-256 hash returned in the final soap event. Pass it back on subsequent
requests to enable cache optimization. If the text has not changed enough to
warrant regeneration, DELPHOS returns the cached result instantly.
Can I send accumulated_text as null?
Yes. A null value is automatically coerced to an empty string (""). However,
an empty string will not produce meaningful SOAP output.
Should I send only new text or the full text? Always send the full accumulated text. DELPHOS needs the complete clinical narrative to generate accurate, contextual SOAP notes. Sending only deltas results in incomplete or incoherent output.
How do I switch to non-streaming mode?
Set "stream": false in the request body, or use the non-streaming endpoint
POST /v1/consultation/progressive-soap directly. Both return the same SOAP
structure as a single JSON response.
Related Articles
- Real-Time Transcription — Audio transcription during consultations
- Consultation Lifecycle — Full consultation workflow
- Streaming Prescription — Real-time prescription extraction
- API Explorer — Browse all DELPHOS endpoints