What you will build
20 min | Intermediate
- Login and create an ambient session
- Stream raw PCM audio over WebSocket
- End the session and poll for note results
- Handle common API errors
Using an AI coding tool?Copy the following prompt to add the Suki developer documentation as a skill and MCP server to your tool for better AI-assisted coding during the integration process. For all AI options (contextual menu,
llms.txt, and skill.md), refer to AI-optimized documentation.Install the Suki developer docs as a skill to get context on Suki's developer tools, APIs, and SDKs. Add the MCP server for documentation search.
https://sdp.suki.ai and wss://sdp.suki.ai/ws/stream API, and handles common API errors.
Prerequisites
Before you begin, make sure you have:- Completed Partner onboarding and received your partner credentials.
- You have
partner_idandpartner_token. - Optional
provider_idorsdp_provider_idif your integration uses Bearer authentication or the Single Auth Token flow. - An audio file in LINEAR16 PCM format (16 kHz, mono, 16-bit little-endian). If you’re using a WAV file, remove the WAV header before streaming and send only the raw PCM audio.
Store your
partner_token only on your backend. Never expose it in a client-side or public application.Architecture
Project setup
- Create a script or web page in the language used by this tutorial (Python, Go, or TypeScript).
-
Install the required dependencies:
-
Python:
pip install requests websocket-client -
Go:
go get github.com/gorilla/websocket -
TypeScript (browser): No additional packages are required. The example uses the built-in
fetchandWebSocketAPIs.
-
Python:
-
Configure your credentials:
- Add your
partner_id. - Store
partner_tokenon your backend only. Never expose it in browser code. - Add
provider_idorsdp_provider_idonly if your integration requires them.
- Add your
- Prepare an audio file in LINEAR16 PCM format or a WAV file. If you use a WAV file, remove the 44-byte WAV header before streaming the PCM audio.
How WebSocket authentication works in this tutorial
| Language | How to authenticate |
|---|---|
| Python or Go | Send sdp_suki_token and ambient_session_id as HTTP headers (sdp_provider_id when required) |
| TypeScript (browser) | Send Sec-WebSocket-Protocol: SukiAmbientAuth,<sdp_suki_token>,<ambient_session_id> |
Important points to remember:
- If login fails because the provider is new, Register provider once, then log in again.
- Send audio at least every 25 seconds, or the server closes the stream. While paused, send
KEEP_ALIVEevery five seconds. - Send raw LINEAR16 PCM. If you start from a WAV file, strip the 44-byte header first. Do not send binary WebSocket frames on this endpoint.
- Browser protocol order is token, then session ID (
SukiAmbientAuth,<sdp_suki_token>,<ambient_session_id>).
Tutorial steps
Work through each step in order. Read the explanation, review the code example, then continue to the next step. The full code example is available in the accordion below.1
Log In
Call the Login API with your partner credentials to receive a
suki_token. Send that token later as sdp_suki_token. If the provider is new, register once, then log in again.def login(partner_id: str, partner_token: str, provider_id: Optional[str] = None) -> str:
url = f"{BASE_URL}/api/v1/auth/login"
payload = {"partner_id": partner_id, "partner_token": partner_token}
if provider_id:
payload["provider_id"] = provider_id
r = requests.post(url, json=payload, timeout=60)
expect_status(r, url, 200)
return r.json()["suki_token"]
# If login fails with provider_not_registered, call register once, then login again.
token = login_with_register_fallback(
PARTNER_ID, PARTNER_TOKEN, PROVIDER_ID, "Dr Example", "org-1" # Example provider name / org only
)
async function login(partnerId: string, partnerToken: string, providerId?: string) {
const url = `${BASE_URL}/api/v1/auth/login`;
const body: Record<string, string> = {
partner_id: partnerId,
partner_token: partnerToken,
};
if (providerId) body.provider_id = providerId;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
await expectStatus(response, url, 200);
const data = await response.json();
return data.suki_token as string;
}
const token = await loginWithRegisterFallback(
partnerId, partnerToken, providerId, 'Dr Example', 'org-1' // Example provider name / org only
);
func login(partnerID, partnerToken, providerID string) (string, error) {
url := baseURL + "/api/v1/auth/login"
body := map[string]string{
"partner_id": partnerID, "partner_token": partnerToken,
}
if providerID != "" {
body["provider_id"] = providerID
}
raw, _ := json.Marshal(body)
resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("login failed: HTTP %d: %s", resp.StatusCode, string(b))
}
var out struct {
SukiToken string `json:"suki_token"`
}
if err := json.Unmarshal(b, &out); err != nil {
return "", err
}
if out.SukiToken == "" {
return "", fmt.Errorf("login response missing suki_token")
}
return out.SukiToken, nil
}
2
Create an Ambient Session
Create a session with the Ambient session API. A successful response returns
ambient_session_id (HTTP 201), which you use for context, streaming, end, and retrieve.def create_ambient_session(suki_token: str, body: Optional[dict[str, str]] = None) -> str:
url = f"{BASE_URL}/api/v1/ambient/session/create"
r = requests.post(url, headers=rest_headers(suki_token), json=body or {}, timeout=60)
expect_status(r, url, 201)
return r.json()["ambient_session_id"]
ambient_session_id = create_ambient_session(token)
async function createAmbientSession(sukiToken: string, body = {}) {
const url = `${BASE_URL}/api/v1/ambient/session/create`;
const response = await fetch(url, {
method: 'POST',
headers: restHeaders(sukiToken),
body: JSON.stringify(body),
});
await expectStatus(response, url, 201);
const data = await response.json();
return data.ambient_session_id as string;
}
const ambientSessionId = await createAmbientSession(token);
func createAmbientSession(sukiToken string, body map[string]string) (string, error) {
var out createSessionResponse
err := doJSON(http.MethodPost, baseURL+"/api/v1/ambient/session/create",
sukiToken, body, http.StatusCreated, &out)
if err != nil {
return "", err
}
return out.AmbientSessionID, nil
}
3
Add Session Context
Seed provider, patient, and visit context with the Context API before you open the WebSocket so note generation has the encounter details it needs.
def seed_context(suki_token: str, ambient_session_id: str) -> None:
url = f"{BASE_URL}/api/v1/ambient/session/{ambient_session_id}/context"
payload = {
"provider": {"specialty": "CARDIOLOGY", "provider_role": "ATTENDING"},
"patient": {"dob": "2000-01-01", "sex": "male"},
"visit": {
"chief_complaint": "Headache",
"encounter_type": "AMBULATORY",
"reason_for_visit": "Follow-up for migraines",
"visit_type": "ESTABLISHED_PATIENT",
},
}
r = requests.post(url, headers=rest_headers(suki_token), json=payload, timeout=60)
expect_status(r, url, 200)
seed_context(token, ambient_session_id)
async function seedContext(sukiToken: string, ambientSessionId: string) {
const url = `${BASE_URL}/api/v1/ambient/session/${ambientSessionId}/context`;
const response = await fetch(url, {
method: 'POST',
headers: restHeaders(sukiToken),
body: JSON.stringify({
provider: { specialty: 'CARDIOLOGY', provider_role: 'ATTENDING' },
patient: { dob: '2000-01-01', sex: 'male' },
visit: {
chief_complaint: 'Headache',
encounter_type: 'AMBULATORY',
reason_for_visit: 'Follow-up for migraines',
visit_type: 'ESTABLISHED_PATIENT',
},
}),
});
await expectStatus(response, url, 200);
}
func seedContext(sukiToken, ambientSessionID string) error {
payload := map[string]any{
"provider": map[string]string{"specialty": "CARDIOLOGY", "provider_role": "ATTENDING"},
"patient": map[string]string{"dob": "2000-01-01", "sex": "male"},
"visit": map[string]string{
"chief_complaint": "Headache", "encounter_type": "AMBULATORY",
"reason_for_visit": "Follow-up for migraines", "visit_type": "ESTABLISHED_PATIENT",
},
}
return doJSON(http.MethodPost,
fmt.Sprintf("%s/api/v1/ambient/session/%s/context", baseURL, ambientSessionID),
sukiToken, payload, http.StatusOK, nil)
}
4
Open the WebSocket
Connect to
wss://sdp.suki.ai/ws/stream. Python and Go send sdp_suki_token and ambient_session_id as HTTP headers. Browser TypeScript uses Sec-WebSocket-Protocol: SukiAmbientAuth,<sdp_suki_token>,<ambient_session_id>.ws_headers = [
f"sdp_suki_token: {suki_token}",
f"ambient_session_id: {ambient_session_id}",
]
if SDP_PROVIDER_ID:
ws_headers.append(f"sdp_provider_id: {SDP_PROVIDER_ID}")
ws = websocket.create_connection(WS_URL, header=ws_headers, timeout=60)
const ws = new WebSocket(WS_URL, [
`SukiAmbientAuth,${sukiToken},${ambientSessionId}`,
]);
ws.binaryType = 'arraybuffer';
hdr := http.Header{}
hdr.Set("sdp_suki_token", sukiToken)
hdr.Set("ambient_session_id", ambientSessionID)
if sdpProviderID != "" {
hdr.Set("sdp_provider_id", sdpProviderID)
}
conn, _, err := websocket.DefaultDialer.Dial(wsURL, hdr)
5
Stream Audio
Send
START_TIME, then JSON AUDIO frames with base64 LINEAR16 PCM (~100 ms chunks). End the segment with RU9G. Do not send binary WebSocket frames on this endpoint. Strip a WAV header first if needed.rfc3339 = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
send_json(ws, {"type": "START_TIME", "data": b64(rfc3339.encode("utf-8"))})
pcm = strip_wav_header(open(pcm_path, "rb").read())
for i in range(0, len(pcm), CHUNK_BYTES):
send_json(ws, {"type": "AUDIO", "data": b64(pcm[i : i + CHUNK_BYTES])})
send_json(ws, {"type": "AUDIO", "data": "RU9G"}) # end marker
// After open: send START_TIME once, then ~100 ms AUDIO frames, then RU9G.
sendJson({ type: 'START_TIME', data: utf8ToBase64(rfc3339) });
sendJson({ type: 'AUDIO', data: bytesToBase64(frame) });
sendJson({ type: 'AUDIO', data: 'RU9G' });
rfc3339 := time.Now().UTC().Format("2006-01-02T15:04:05Z")
_ = sendJSON(conn, map[string]any{"type": "START_TIME", "data": b64([]byte(rfc3339))})
// Write AUDIO frames from stripWavHeader(pcm)...
_ = sendJSON(conn, map[string]any{"type": "AUDIO", "data": "RU9G"})
6
Optional Control Events
While streaming you can send
PAUSE, RESUME, or CANCEL as EVENT messages. While paused, send KEEP_ALIVE about every five seconds so idle timeouts do not close the connection. Prefer CANCEL to end the session. ABORT is deprecated and is not handled by the stream handler.send_event(ws, "PAUSE")
# while paused:
send_event(ws, "KEEP_ALIVE")
time.sleep(KEEP_ALIVE_SEC)
send_event(ws, "RESUME")
sendEvent('PAUSE');
// KEEP_ALIVE on an interval while paused
sendEvent('RESUME');
_ = sendEvent(conn, "PAUSE")
_ = sendEvent(conn, "KEEP_ALIVE")
_ = sendEvent(conn, "RESUME")
7
End the Session and Retrieve Results
After the WebSocket closes, call End ambient session, poll status until processing finishes, then fetch transcript and content with the retrieve APIs.
end_ambient_session(token, ambient_session_id)
results = wait_for_results(token, ambient_session_id)
print(results["transcript"], results["content"])
await endAmbientSession(token, ambientSessionId);
const results = await waitForResults(token, ambientSessionId);
console.log(results.transcript, results.content);
if err := endAmbientSession(token, ambientSessionID); err != nil {
panic(err)
}
results, err := waitForResults(token, ambientSessionID)
8
Handle API Errors
REST failures return JSON with
code, message, and details. Map those fields into your client errors and use documented hints for cases such as invalid_sdp_token or provider_not_registered. See API error messages.class SukiApiError(RuntimeError):
def __init__(self, http_status, url, *, code=None, message="", details=None, raw_body=""):
self.http_status = http_status
self.code = code
self.message = message or ""
self.details = details if details is not None else []
...
def hint_for_error(err: SukiApiError) -> str:
if err.matches("invalid_sdp_token"):
return "Missing, expired, or invalid sdp_suki_token. Call login again."
if err.matches("provider_not_registered"):
return "Provider is not registered. Call register once, then login again."
return "See /api-reference/error-messages"
class SukiApiError extends Error {
constructor(
public httpStatus: number,
public url: string,
opts: { code?: unknown; message?: string; details?: unknown } = {},
) {
super(`HTTP ${httpStatus} ${url}: ${opts.message ?? ''}`);
this.code = opts.code;
this.messageText = opts.message ?? '';
}
}
type SukiApiError struct {
HTTPStatus int
URL string
Code any
Message string
Details any
}
func hintForError(err *SukiApiError) string {
if err.Matches("invalid_sdp_token") {
return "Missing, expired, or invalid sdp_suki_token. Call login again."
}
if err.Matches("provider_not_registered") {
return "Provider is not registered. Call register once, then login again."
}
return "See /api-reference/error-messages"
}
Full code example
Python
# Ambient streaming tutorial (Python)
# Flow: login → create session → add context → stream PCM over WebSocket → end session → poll results
# Install: pip install requests websocket-client
import base64
import json
import time
from datetime import datetime, timezone
from typing import Any, Optional
import requests
import websocket
# ---------------------------------------------------------------------------
# Endpoints and streaming defaults
# ---------------------------------------------------------------------------
BASE_URL = "https://sdp.suki.ai" # REST API host
WS_URL = "wss://sdp.suki.ai/ws/stream" # Ambient WebSocket stream
CHUNK_BYTES = 3200 # ~100 ms of 16 kHz mono 16-bit PCM
WAV_HEADER_BYTES = 44 # Standard WAV header size to strip
KEEP_ALIVE_SEC = 5 # Send KEEP_ALIVE this often while paused
# ---------------------------------------------------------------------------
# Replace these with your partner credentials
# ---------------------------------------------------------------------------
PARTNER_ID = "your-partner-id" # Example only — replace with your partner ID
PARTNER_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." # Example only — replace; keep on backend only
PROVIDER_ID = "provider-123" # Example only — optional; required for Bearer / Single Auth Token partners
SDP_PROVIDER_ID = "" # Optional. Leave empty unless your partnership requires sdp_provider_id
# ---------------------------------------------------------------------------
# Error helpers: map REST failures to the documented code / message / details shape
# ---------------------------------------------------------------------------
class SukiApiError(RuntimeError):
"""REST failure with the documented {code, message, details} body when present."""
def __init__(
self,
http_status: int,
url: str,
*,
code: Any = None,
message: str = "",
details: Any = None,
raw_body: str = "",
) -> None:
self.http_status = http_status
self.url = url
self.code = code
self.message = message or ""
self.details = details if details is not None else []
self.raw_body = raw_body
super().__init__(
f"HTTP {http_status} {url}: code={code!r} message={self.message!r}"
)
def matches(self, error_id: str) -> bool:
# Docs: match error id exactly or as a prefix (for example invalid_sdp_token: …).
m = self.message.strip()
return m == error_id or m.startswith(error_id + ":") or m.startswith(error_id + " ")
def parse_suki_error_body(raw: str) -> tuple[Any, str, Any]:
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
return None, (raw or "")[:500], []
if not isinstance(data, dict):
return None, (raw or "")[:500], []
message = data.get("message")
return data.get("code"), str(message) if message is not None else "", data.get("details") or []
def raise_suki_api_error(response: requests.Response, url: str) -> None:
raw = response.text or ""
code, message, details = parse_suki_error_body(raw)
raise SukiApiError(
response.status_code,
url,
code=code,
message=message,
details=details,
raw_body=raw,
)
def expect_status(response: requests.Response, url: str, want: int) -> None:
if response.status_code != want:
raise_suki_api_error(response, url)
def hint_for_error(err: SukiApiError) -> str:
if err.matches("invalid_sdp_token") or "invalid sdp token" in err.message.lower():
return "Missing, expired, or invalid sdp_suki_token. Call login again."
if err.matches("provider_not_registered"):
return "Provider is not registered. Call register once, then login again."
if err.http_status == 401:
return "Unauthorized. Check partner credentials and sdp_suki_token."
if err.http_status >= 500:
return "Server error. Retry with backoff; see API error messages."
return "See /api-reference/error-messages"
def b64(data: bytes) -> str:
return base64.b64encode(data).decode("ascii")
def rest_headers(suki_token: str) -> dict[str, str]:
headers = {
"sdp_suki_token": suki_token,
"Content-Type": "application/json",
}
# Include only when your partnership requires it (leave SDP_PROVIDER_ID empty otherwise).
if SDP_PROVIDER_ID:
headers["sdp_provider_id"] = SDP_PROVIDER_ID
return headers
# ---------------------------------------------------------------------------
# Authentication
# ---------------------------------------------------------------------------
def login(partner_id: str, partner_token: str, provider_id: Optional[str] = None) -> str:
"""Exchange partner credentials for a Suki token (send later as sdp_suki_token)."""
url = f"{BASE_URL}/api/v1/auth/login"
payload: dict[str, Any] = {
"partner_id": partner_id,
"partner_token": partner_token,
}
if provider_id:
payload["provider_id"] = provider_id
r = requests.post(url, json=payload, timeout=60)
expect_status(r, url, 200)
token = r.json().get("suki_token")
if not token:
raise SukiApiError(200, url, message="login response missing suki_token")
return token
def register_provider(
partner_id: str,
partner_token: str,
provider_name: str,
provider_org_id: str,
provider_id: Optional[str] = None,
provider_specialty: str = "CARDIOLOGY",
) -> None:
"""POST /api/v1/auth/register (one-time)."""
url = f"{BASE_URL}/api/v1/auth/register"
payload: dict[str, Any] = {
"partner_id": partner_id,
"partner_token": partner_token,
"provider_name": provider_name,
"provider_org_id": provider_org_id,
"provider_specialty": provider_specialty,
}
if provider_id:
payload["provider_id"] = provider_id
r = requests.post(url, json=payload, timeout=60)
if r.status_code not in (201, 409):
raise_suki_api_error(r, url)
def login_with_register_fallback(
partner_id: str,
partner_token: str,
provider_id: Optional[str],
provider_name: str,
provider_org_id: str,
) -> str:
try:
return login(partner_id, partner_token, provider_id)
except SukiApiError as err:
# Only register when the API indicates an unregistered provider.
if err.matches("provider_not_registered"):
print(hint_for_error(err))
register_provider(
partner_id,
partner_token,
provider_name=provider_name,
provider_org_id=provider_org_id,
provider_id=provider_id,
)
return login(partner_id, partner_token, provider_id)
print(hint_for_error(err))
raise
# ---------------------------------------------------------------------------
# Ambient session setup
# ---------------------------------------------------------------------------
def create_ambient_session(suki_token: str, body: Optional[dict[str, str]] = None) -> str:
"""Create an Ambient session and return ambient_session_id (HTTP 201)."""
url = f"{BASE_URL}/api/v1/ambient/session/create"
r = requests.post(url, headers=rest_headers(suki_token), json=body or {}, timeout=60)
expect_status(r, url, 201)
sid = r.json().get("ambient_session_id")
if not sid:
raise SukiApiError(201, url, message="create response missing ambient_session_id")
return sid
def seed_context(suki_token: str, ambient_session_id: str) -> None:
"""Add initial provider, patient, and visit context before streaming."""
url = f"{BASE_URL}/api/v1/ambient/session/{ambient_session_id}/context"
payload = {
"provider": {"specialty": "CARDIOLOGY", "provider_role": "ATTENDING"},
"patient": {"dob": "2000-01-01", "sex": "male"},
"visit": {
"chief_complaint": "Headache",
"encounter_type": "AMBULATORY",
"reason_for_visit": "Follow-up for migraines",
"visit_type": "ESTABLISHED_PATIENT",
},
"sections": [{"loinc": "10164-2"}, {"loinc": "48765-2"}],
"diagnoses": {
"values": [
{
"codes": [
{
"code": "I10", # Example ICD10 — replace with your diagnosis
"description": "Essential hypertension", # Example only
"type": "ICD10",
}
],
"diagnosis_note": "Hypertension", # Example only
}
]
},
"emr": {"target_emr": "EPIC"},
}
r = requests.post(url, headers=rest_headers(suki_token), json=payload, timeout=60)
expect_status(r, url, 200)
def strip_wav_header(raw: bytes) -> bytes:
"""If the file is WAV, drop the 44-byte header and keep only raw PCM bytes."""
if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WAVE":
return raw[WAV_HEADER_BYTES:]
return raw
def send_json(ws: websocket.WebSocket, obj: dict[str, Any]) -> None:
ws.send(json.dumps(obj))
def send_event(ws: websocket.WebSocket, event: str) -> None:
send_json(ws, {"type": "EVENT", "event": event})
# ---------------------------------------------------------------------------
# WebSocket streaming (Python / Go style: auth headers, not Sec-WebSocket-Protocol)
# ---------------------------------------------------------------------------
def stream_pcm_file(
suki_token: str,
ambient_session_id: str,
pcm_or_wav_path: str,
) -> None:
"""
Open /ws/stream, send START_TIME, stream LINEAR16 PCM chunks, then end with RU9G.
Auth uses HTTP headers: sdp_suki_token + ambient_session_id (+ sdp_provider_id when required).
"""
try:
ws = websocket.create_connection(
WS_URL,
header=(
[
f"sdp_suki_token: {suki_token}",
f"ambient_session_id: {ambient_session_id}",
]
+ ([f"sdp_provider_id: {SDP_PROVIDER_ID}"] if SDP_PROVIDER_ID else [])
),
timeout=60,
)
except websocket.WebSocketException as err:
raise RuntimeError(f"WebSocket connect failed: {err}") from err
try:
# Mark the start of this audio segment (RFC3339 timestamp, base64-encoded).
rfc3339 = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
send_json(ws, {"type": "START_TIME", "data": b64(rfc3339.encode("utf-8"))})
with open(pcm_or_wav_path, "rb") as f:
pcm = strip_wav_header(f.read())
# Send ~100 ms JSON AUDIO frames (base64 PCM). Do not send binary WebSocket frames.
for i in range(0, len(pcm), CHUNK_BYTES):
chunk = pcm[i : i + CHUNK_BYTES]
send_json(ws, {"type": "AUDIO", "data": b64(chunk)})
# Optional controls while streaming:
# send_event(ws, "PAUSE")
# while paused: send_event(ws, "KEEP_ALIVE"); time.sleep(KEEP_ALIVE_SEC)
# send_event(ws, "RESUME")
# send_event(ws, "CANCEL") # cancels the session; server closes the socket
# ABORT is deprecated and is not handled by the stream handler
# Ambient end marker for this stream segment (EOF as base64 text "RU9G").
send_json(ws, {"type": "AUDIO", "data": "RU9G"})
except (OSError, websocket.WebSocketException) as err:
raise RuntimeError(f"WebSocket stream failed: {err}") from err
finally:
try:
ws.close()
except Exception:
pass
# ---------------------------------------------------------------------------
# End session and fetch results over REST
# ---------------------------------------------------------------------------
def end_ambient_session(suki_token: str, ambient_session_id: str) -> None:
"""End the Ambient session after the WebSocket stream is closed."""
url = f"{BASE_URL}/api/v1/ambient/session/{ambient_session_id}/end"
r = requests.post(url, headers=rest_headers(suki_token), timeout=60)
expect_status(r, url, 200)
def get_status(suki_token: str, ambient_session_id: str) -> str:
url = f"{BASE_URL}/api/v1/ambient/session/{ambient_session_id}/status"
r = requests.get(url, headers=rest_headers(suki_token), timeout=60)
expect_status(r, url, 200)
return r.json()["status"]
def get_transcript(suki_token: str, ambient_session_id: str) -> Any:
url = f"{BASE_URL}/api/v1/ambient/session/{ambient_session_id}/transcript"
r = requests.get(url, headers=rest_headers(suki_token), timeout=60)
expect_status(r, url, 200)
return r.json()
def get_content(suki_token: str, ambient_session_id: str) -> Any:
url = f"{BASE_URL}/api/v1/ambient/session/{ambient_session_id}/content"
r = requests.get(
url,
params={"cumulative": "false"},
headers=rest_headers(suki_token),
timeout=60,
)
expect_status(r, url, 200)
return r.json()
def wait_for_results(suki_token: str, ambient_session_id: str, poll_sec: float = 2.0) -> dict[str, Any]:
"""Poll session status until completed (or failed / skipped / aborted), then load results."""
while True:
status = get_status(suki_token, ambient_session_id)
if status == "completed":
return {
"status": status,
"transcript": get_transcript(suki_token, ambient_session_id),
"content": get_content(suki_token, ambient_session_id),
}
if status in ("failed", "skipped", "aborted"):
return {"status": status, "transcript": None, "content": None}
time.sleep(poll_sec)
# ---------------------------------------------------------------------------
# Run the full flow end to end
# ---------------------------------------------------------------------------
if __name__ == "__main__":
try:
# 1) Authenticate (register once if the provider is new).
suki_token = login_with_register_fallback(
PARTNER_ID,
PARTNER_TOKEN,
PROVIDER_ID,
provider_name="Dr. John Smith", # Example only — replace with the provider's name
provider_org_id="org-123", # Example only — replace with your org ID
)
# 2) Create the session and add context.
ambient_session_id = create_ambient_session(suki_token, {})
seed_context(suki_token, ambient_session_id)
# 3) Stream audio, then end the session and wait for results.
stream_pcm_file(suki_token, ambient_session_id, "audio.wav") # Example path — replace with your LINEAR16 PCM/WAV file
end_ambient_session(suki_token, ambient_session_id)
print(wait_for_results(suki_token, ambient_session_id))
except SukiApiError as err:
print(hint_for_error(err))
raise
TypeScript
// Ambient streaming tutorial (TypeScript / browser)
// Flow: login → create session → add context → stream PCM over WebSocket → end session → poll results
// Browser WebSocket auth uses Sec-WebSocket-Protocol (not HTTP headers).
// Prefer running login / create / context on your backend so partner_token never ships to the client.
// ---------------------------------------------------------------------------
// Endpoints and streaming defaults
// ---------------------------------------------------------------------------
const BASE_URL = 'https://sdp.suki.ai'; // REST API host
const WS_URL = 'wss://sdp.suki.ai/ws/stream'; // Ambient WebSocket stream
const CHUNK_BYTES = 3200; // ~100 ms of 16 kHz mono 16-bit PCM
const WAV_HEADER_BYTES = 44; // Standard WAV header size to strip
const KEEP_ALIVE_MS = 5000; // Send KEEP_ALIVE this often while paused
// ---------------------------------------------------------------------------
// Replace these with your partner credentials
// ---------------------------------------------------------------------------
const partnerId = 'your-partner-id'; // Example only — replace with your partner ID
const partnerToken = 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...'; // Example only — replace; keep on backend only
const providerId = 'provider-123'; // Example only — optional; required for Bearer / Single Auth Token partners
const sdpProviderId = ''; // Optional. Leave empty unless your partnership requires sdp_provider_id
type ControlEvent = 'PAUSE' | 'RESUME' | 'KEEP_ALIVE' | 'CANCEL';
// ---------------------------------------------------------------------------
// Error helpers: map REST failures to the documented code / message / details shape
// ---------------------------------------------------------------------------
/** REST failure with documented { code, message, details } when present. */
class SukiApiError extends Error {
httpStatus: number;
url: string;
code: unknown;
messageText: string;
details: unknown;
rawBody: string;
constructor(
httpStatus: number,
url: string,
opts: { code?: unknown; message?: string; details?: unknown; rawBody?: string } = {},
) {
super(
`HTTP ${httpStatus} ${url}: code=${String(opts.code)} message=${opts.message ?? ''}`,
);
this.name = 'SukiApiError';
this.httpStatus = httpStatus;
this.url = url;
this.code = opts.code;
this.messageText = opts.message ?? '';
this.details = opts.details ?? [];
this.rawBody = opts.rawBody ?? '';
}
/** Match error id exactly or as a prefix (see API error messages). */
matches(errorId: string): boolean {
const m = this.messageText.trim();
return m === errorId || m.startsWith(`${errorId}:`) || m.startsWith(`${errorId} `);
}
}
function parseSukiErrorBody(raw: string): {
code?: unknown;
message: string;
details: unknown;
} {
try {
const data = raw ? JSON.parse(raw) : {};
if (data && typeof data === 'object' && !Array.isArray(data)) {
return {
code: (data as any).code,
message: (data as any).message != null ? String((data as any).message) : '',
details: (data as any).details ?? [],
};
}
} catch {
// non-JSON body
}
return { message: raw.slice(0, 500), details: [] };
}
async function expectStatus(
response: Response,
url: string,
want: number,
): Promise<void> {
if (response.status === want) return;
const rawBody = await response.text();
const parsed = parseSukiErrorBody(rawBody);
throw new SukiApiError(response.status, url, {
code: parsed.code,
message: parsed.message,
details: parsed.details,
rawBody,
});
}
function hintForError(err: SukiApiError): string {
if (
err.matches('invalid_sdp_token') ||
err.messageText.toLowerCase().includes('invalid sdp token')
) {
return 'Missing, expired, or invalid sdp_suki_token. Call login again.';
}
if (err.matches('provider_not_registered')) {
return 'Provider is not registered. Call register once, then login again.';
}
if (err.httpStatus === 401) {
return 'Unauthorized. Check partner credentials and sdp_suki_token.';
}
if (err.httpStatus >= 500) {
return 'Server error. Retry with backoff; see API error messages.';
}
return 'See /api-reference/error-messages';
}
function restHeaders(sukiToken: string): HeadersInit {
const headers: Record<string, string> = {
sdp_suki_token: sukiToken,
'Content-Type': 'application/json',
};
// Include only when your partnership requires it (leave sdpProviderId empty otherwise).
if (sdpProviderId) headers.sdp_provider_id = sdpProviderId;
return headers;
}
// ---------------------------------------------------------------------------
// Authentication
// ---------------------------------------------------------------------------
/** Exchange partner credentials for a Suki token (send later as sdp_suki_token). */
async function login(
partnerId: string,
partnerToken: string,
providerId?: string,
): Promise<string> {
const url = `${BASE_URL}/api/v1/auth/login`;
const body: Record<string, string> = {
partner_id: partnerId,
partner_token: partnerToken,
};
if (providerId) body.provider_id = providerId;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
await expectStatus(response, url, 200);
const data = await response.json();
if (!data.suki_token) {
throw new SukiApiError(200, url, { message: 'login response missing suki_token' });
}
return data.suki_token as string;
}
/** POST /api/v1/auth/register (one-time). */
async function registerProvider(input: {
partnerId: string;
partnerToken: string;
providerName: string;
providerOrgId: string;
providerId?: string;
providerSpecialty?: string;
}): Promise<void> {
const url = `${BASE_URL}/api/v1/auth/register`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
partner_id: input.partnerId,
partner_token: input.partnerToken,
provider_name: input.providerName,
provider_org_id: input.providerOrgId,
provider_id: input.providerId,
provider_specialty: input.providerSpecialty ?? 'CARDIOLOGY',
}),
});
if (response.status !== 201 && response.status !== 409) {
await expectStatus(response, url, 201);
}
}
async function loginWithRegisterFallback(
partnerId: string,
partnerToken: string,
providerId: string | undefined,
providerName: string,
providerOrgId: string,
): Promise<string> {
try {
return await login(partnerId, partnerToken, providerId);
} catch (err) {
if (err instanceof SukiApiError && err.matches('provider_not_registered')) {
console.warn(hintForError(err));
await registerProvider({
partnerId,
partnerToken,
providerName,
providerOrgId,
providerId,
});
return login(partnerId, partnerToken, providerId);
}
if (err instanceof SukiApiError) console.warn(hintForError(err));
throw err;
}
}
// ---------------------------------------------------------------------------
// Ambient session setup
// ---------------------------------------------------------------------------
/** Create an Ambient session and return ambient_session_id (HTTP 201). */
async function createAmbientSession(
sukiToken: string,
body: { ambient_session_id?: string; encounter_id?: string } = {},
): Promise<string> {
const url = `${BASE_URL}/api/v1/ambient/session/create`;
const response = await fetch(url, {
method: 'POST',
headers: restHeaders(sukiToken),
body: JSON.stringify(body),
});
await expectStatus(response, url, 201);
const data = await response.json();
if (!data.ambient_session_id) {
throw new SukiApiError(201, url, {
message: 'create response missing ambient_session_id',
});
}
return data.ambient_session_id as string;
}
/** Add initial provider, patient, and visit context before streaming. */
async function seedContext(sukiToken: string, ambientSessionId: string): Promise<void> {
const url = `${BASE_URL}/api/v1/ambient/session/${ambientSessionId}/context`;
const response = await fetch(url, {
method: 'POST',
headers: restHeaders(sukiToken),
body: JSON.stringify({
provider: { specialty: 'CARDIOLOGY', provider_role: 'ATTENDING' },
patient: { dob: '2000-01-01', sex: 'male' },
visit: {
chief_complaint: 'Headache',
encounter_type: 'AMBULATORY',
reason_for_visit: 'Follow-up for migraines',
visit_type: 'ESTABLISHED_PATIENT',
},
sections: [{ loinc: '10164-2' }, { loinc: '48765-2' }],
diagnoses: {
values: [
{
codes: [
{
code: 'I10', // Example ICD10 — replace with your diagnosis
description: 'Essential hypertension', // Example only
type: 'ICD10',
},
],
diagnosis_note: 'Hypertension', // Example only
},
],
},
emr: { target_emr: 'EPIC' },
}),
});
await expectStatus(response, url, 200);
}
function utf8ToBase64(s: string): string {
const bytes = new TextEncoder().encode(s);
let binary = '';
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!);
return btoa(binary);
}
function bytesToBase64(u8: Uint8Array): string {
let binary = '';
for (let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i]!);
return btoa(binary);
}
/** If the file is WAV, drop the 44-byte header and keep only raw PCM bytes. */
function stripWavHeader(buf: Uint8Array): Uint8Array {
if (
buf.length >= 12 &&
buf[0] === 0x52 &&
buf[1] === 0x49 &&
buf[2] === 0x46 &&
buf[3] === 0x46 &&
buf[8] === 0x57 &&
buf[9] === 0x41 &&
buf[10] === 0x56 &&
buf[11] === 0x45
) {
return buf.subarray(WAV_HEADER_BYTES);
}
return buf;
}
// ---------------------------------------------------------------------------
// WebSocket streaming (browser: Sec-WebSocket-Protocol auth)
// ---------------------------------------------------------------------------
/**
* Browser stream client.
* Authenticate with: Sec-WebSocket-Protocol = SukiAmbientAuth,<sdp_suki_token>,<ambient_session_id>
* Sends START_TIME, JSON AUDIO chunks (~100 ms), optional EVENTs, then RU9G to end the segment.
*/
function createAmbientStreamClient(sukiToken: string, ambientSessionId: string) {
let pcmBuffer = new Uint8Array(0);
const pending: string[] = [];
let startSent = false;
let paused = false;
let keepAliveTimer: ReturnType<typeof setInterval> | null = null;
let closing = false;
let onSocketClosed: (() => void) | undefined;
let lastSocketError: Event | undefined;
const ws = new WebSocket(WS_URL, [
`SukiAmbientAuth,${sukiToken},${ambientSessionId}`,
]);
ws.binaryType = 'arraybuffer';
function flushPending() {
if (ws.readyState !== WebSocket.OPEN) return;
while (pending.length > 0) ws.send(pending.shift()!);
}
function sendJson(obj: Record<string, unknown>) {
pending.push(JSON.stringify(obj));
flushPending();
}
function ensureStartTime() {
// Mark the start of this audio segment once (RFC3339 timestamp, base64-encoded).
if (startSent) return;
startSent = true;
const rfc3339 = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
pending.unshift(
JSON.stringify({ type: 'START_TIME', data: utf8ToBase64(rfc3339) }),
);
flushPending();
}
function stopKeepAlive() {
if (keepAliveTimer !== null) {
clearInterval(keepAliveTimer);
keepAliveTimer = null;
}
}
function startKeepAlive() {
stopKeepAlive();
sendJson({ type: 'EVENT', event: 'KEEP_ALIVE' });
keepAliveTimer = setInterval(() => {
if (!paused || closing) return;
sendJson({ type: 'EVENT', event: 'KEEP_ALIVE' });
}, KEEP_ALIVE_MS);
}
function sendEvent(event: ControlEvent) {
ensureStartTime();
sendJson({ type: 'EVENT', event });
if (event === 'PAUSE') {
paused = true;
startKeepAlive();
return;
}
if (event === 'RESUME') {
paused = false;
stopKeepAlive();
return;
}
if (event === 'CANCEL') {
paused = false;
stopKeepAlive();
closing = true;
pcmBuffer = new Uint8Array(0);
return;
}
}
function pushPcm(chunk: Uint8Array) {
// Buffer live PCM and flush ~100 ms JSON AUDIO frames (base64). Do not send binary frames.
if (closing) return;
ensureStartTime();
const merged = new Uint8Array(pcmBuffer.length + chunk.length);
merged.set(pcmBuffer, 0);
merged.set(chunk, pcmBuffer.length);
pcmBuffer = merged;
while (pcmBuffer.length >= CHUNK_BYTES) {
const frame = pcmBuffer.slice(0, CHUNK_BYTES);
pcmBuffer = pcmBuffer.slice(CHUNK_BYTES);
sendJson({ type: 'AUDIO', data: bytesToBase64(frame) });
}
}
function closeWhenDrained(onClosed?: () => void) {
onSocketClosed = onClosed;
const tick = () => {
if (ws.readyState === WebSocket.CLOSED) {
const cb = onSocketClosed;
onSocketClosed = undefined;
cb?.();
return;
}
if (ws.readyState === WebSocket.CLOSING) {
setTimeout(tick, 20);
return;
}
flushPending();
if (
pending.length > 0 ||
ws.readyState === WebSocket.CONNECTING ||
ws.bufferedAmount > 0
) {
setTimeout(tick, 20);
return;
}
if (ws.readyState === WebSocket.OPEN) ws.close();
};
tick();
}
function endStream(onClosed?: () => void) {
// Flush remaining PCM, then send the Ambient end marker (EOF as base64 text "RU9G").
if (closing) return;
closing = true;
paused = false;
stopKeepAlive();
ensureStartTime();
if (pcmBuffer.length > 0) {
sendJson({ type: 'AUDIO', data: bytesToBase64(pcmBuffer) });
pcmBuffer = new Uint8Array(0);
}
sendJson({ type: 'AUDIO', data: 'RU9G' });
closeWhenDrained(onClosed);
}
/**
* Stream an entire ArrayBuffer (for example from FileReader.readAsArrayBuffer).
* Strips a RIFF/WAVE header when present, then pushes PCM and ends the stream.
*/
function streamArrayBuffer(buffer: ArrayBuffer, onClosed?: () => void) {
const pcm = stripWavHeader(new Uint8Array(buffer));
for (let offset = 0; offset < pcm.byteLength; offset += CHUNK_BYTES) {
const end = Math.min(offset + CHUNK_BYTES, pcm.byteLength);
pushPcm(pcm.subarray(offset, end));
}
endStream(onClosed);
}
/**
* File input helper. Expects <input type="file" id="audioFile" /> in the page.
*/
function streamFromFileInput(inputId = 'audioFile', onClosed?: () => void) {
const input = document.getElementById(inputId) as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file) {
console.error(`Select a LINEAR16 PCM or WAV file for #${inputId}`);
ws.close();
return;
}
const reader = new FileReader();
reader.onload = () => {
streamArrayBuffer(reader.result as ArrayBuffer, onClosed);
};
reader.readAsArrayBuffer(file);
}
ws.onopen = () => {
ensureStartTime();
flushPending();
};
ws.onmessage = (ev) => {
// Final notes are not guaranteed over WebSocket. Prefer REST.
console.log('Received:', ev.data);
};
ws.onerror = (e) => {
lastSocketError = e;
console.error('WebSocket error', e);
};
ws.onclose = (ev) => {
stopKeepAlive();
if (!ev.wasClean || ev.code !== 1000) {
console.error(
`WebSocket closed abnormally: code=${ev.code} reason=${ev.reason || '(none)'}`,
lastSocketError,
);
}
const cb = onSocketClosed;
onSocketClosed = undefined;
cb?.();
};
return {
ws,
pushPcm,
sendEvent,
endStream,
streamArrayBuffer,
streamFromFileInput,
stripWavHeader,
};
}
// ---------------------------------------------------------------------------
// End session and fetch results over REST
// ---------------------------------------------------------------------------
async function endAmbientSession(sukiToken: string, ambientSessionId: string) {
/** End the Ambient session after the WebSocket stream is closed. */
const url = `${BASE_URL}/api/v1/ambient/session/${ambientSessionId}/end`;
const response = await fetch(url, {
method: 'POST',
headers: restHeaders(sukiToken),
});
await expectStatus(response, url, 200);
}
async function getSessionStatus(sukiToken: string, ambientSessionId: string) {
const url = `${BASE_URL}/api/v1/ambient/session/${ambientSessionId}/status`;
const response = await fetch(url, { headers: restHeaders(sukiToken) });
await expectStatus(response, url, 200);
const body = await response.json();
return body.status as string;
}
async function getTranscript(sukiToken: string, ambientSessionId: string) {
const url = `${BASE_URL}/api/v1/ambient/session/${ambientSessionId}/transcript`;
const response = await fetch(url, { headers: restHeaders(sukiToken) });
await expectStatus(response, url, 200);
return response.json();
}
async function getContent(sukiToken: string, ambientSessionId: string) {
const url = `${BASE_URL}/api/v1/ambient/session/${ambientSessionId}/content?cumulative=false`;
const response = await fetch(url, { headers: restHeaders(sukiToken) });
await expectStatus(response, url, 200);
return response.json();
}
async function waitForResults(
sukiToken: string,
ambientSessionId: string,
pollMs = 2000,
) {
for (;;) {
const status = await getSessionStatus(sukiToken, ambientSessionId);
if (status === 'completed') {
const [transcript, content] = await Promise.all([
getTranscript(sukiToken, ambientSessionId),
getContent(sukiToken, ambientSessionId),
]);
return { status, transcript, content };
}
if (status === 'failed' || status === 'skipped' || status === 'aborted') {
return { status, transcript: null, content: null };
}
await new Promise((r) => setTimeout(r, pollMs));
}
}
// ---------------------------------------------------------------------------
// Run the full flow end to end
// ---------------------------------------------------------------------------
async function main() {
try {
// 1) Authenticate (register once if the provider is new).
const sukiToken = await loginWithRegisterFallback(
partnerId,
partnerToken,
providerId,
'Dr. John Smith', // Example only — replace with the provider's name
'org-123', // Example only — replace with your org ID
);
// 2) Create the session and add context.
const ambientSessionId = await createAmbientSession(sukiToken);
await seedContext(sukiToken, ambientSessionId);
// 3) Stream audio from a file input, then end the session and poll results.
// Add <input type="file" id="audioFile" /> to your HTML before calling main().
const client = createAmbientStreamClient(sukiToken, ambientSessionId);
// Example: expects <input type="file" id="audioFile" /> with a LINEAR16 PCM or WAV file
client.streamFromFileInput('audioFile', async () => {
await endAmbientSession(sukiToken, ambientSessionId);
console.log(await waitForResults(sukiToken, ambientSessionId));
});
// Live capture alternative:
// client.pushPcm(client.stripWavHeader(pcmBytes));
// client.endStream(async () => { ... });
} catch (err) {
if (err instanceof SukiApiError) console.error(hintForError(err), err);
throw err;
}
}
// Prefer a backend proxy for partner_token in production.
// Call main() only after credentials are set and #audioFile has a file selected.
// main();
Go
// Ambient streaming tutorial (Go)
// Flow: login → create session → add context → stream PCM over WebSocket → end session → poll results
// Install: go get github.com/gorilla/websocket
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/gorilla/websocket"
)
// Endpoints, streaming defaults, and partner credentials to replace.
const (
baseURL = "https://sdp.suki.ai" // REST API host
wsURL = "wss://sdp.suki.ai/ws/stream" // Ambient WebSocket stream
chunkBytes = 3200 // ~100 ms of 16 kHz mono 16-bit PCM
wavHeaderBytes = 44 // Standard WAV header size to strip
partnerID = "your-partner-id" // Example only — replace with your partner ID
partnerToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." // Example only — replace; keep on backend only
providerID = "provider-123" // Example only — optional; required for Bearer / Single Auth Token partners
sdpProviderID = "" // Optional. Leave empty unless partnership requires it
)
type loginResponse struct {
SukiToken string `json:"suki_token"`
}
type createSessionResponse struct {
AmbientSessionID string `json:"ambient_session_id"`
}
type statusResponse struct {
Status string `json:"status"`
}
// Error helpers: map REST failures to the documented code / message / details shape.
// SukiApiError is a REST failure with documented {code, message, details} when present.
type SukiApiError struct {
HTTPStatus int
URL string
Code any
Message string
Details any
RawBody string
}
func (e *SukiApiError) Error() string {
return fmt.Sprintf("HTTP %d %s: code=%v message=%q", e.HTTPStatus, e.URL, e.Code, e.Message)
}
// Matches reports whether message equals errorId or uses it as a prefix.
func (e *SukiApiError) Matches(errorID string) bool {
m := strings.TrimSpace(e.Message)
return m == errorID || strings.HasPrefix(m, errorID+":") || strings.HasPrefix(m, errorID+" ")
}
func parseSukiErrorBody(raw string) (code any, message string, details any) {
var data map[string]any
if err := json.Unmarshal([]byte(raw), &data); err != nil {
return nil, truncate(raw, 500), []any{}
}
if msg, ok := data["message"]; ok && msg != nil {
message = fmt.Sprint(msg)
}
return data["code"], message, data["details"]
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func newSukiAPIError(status int, url, raw string) *SukiApiError {
code, message, details := parseSukiErrorBody(raw)
return &SukiApiError{
HTTPStatus: status,
URL: url,
Code: code,
Message: message,
Details: details,
RawBody: raw,
}
}
func hintForError(err *SukiApiError) string {
if err.Matches("invalid_sdp_token") || strings.Contains(strings.ToLower(err.Message), "invalid sdp token") {
return "Missing, expired, or invalid sdp_suki_token. Call login again."
}
if err.Matches("provider_not_registered") {
return "Provider is not registered. Call register once, then login again."
}
if err.HTTPStatus == http.StatusUnauthorized {
return "Unauthorized. Check partner credentials and sdp_suki_token."
}
if err.HTTPStatus >= 500 {
return "Server error. Retry with backoff; see API error messages."
}
return "See /api-reference/error-messages"
}
func b64(data []byte) string {
return base64.StdEncoding.EncodeToString(data)
}
func restHeaders(sukiToken string) http.Header {
h := make(http.Header)
h.Set("sdp_suki_token", sukiToken)
h.Set("Content-Type", "application/json")
if sdpProviderID != "" {
h.Set("sdp_provider_id", sdpProviderID)
}
return h
}
// Authentication: exchange partner credentials for a Suki token (send later as sdp_suki_token).
func login(partnerID, partnerToken, providerID string) (string, error) {
url := baseURL + "/api/v1/auth/login"
body := map[string]string{
"partner_id": partnerID,
"partner_token": partnerToken,
}
if providerID != "" {
body["provider_id"] = providerID
}
raw, _ := json.Marshal(body)
resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
if err != nil {
return "", err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", newSukiAPIError(resp.StatusCode, url, string(b))
}
var out loginResponse
if err := json.Unmarshal(b, &out); err != nil {
return "", err
}
if out.SukiToken == "" {
return "", &SukiApiError{HTTPStatus: 200, URL: url, Message: "login response missing suki_token"}
}
return out.SukiToken, nil
}
func registerProvider(partnerID, partnerToken, providerName, providerOrgID, providerID string) error {
url := baseURL + "/api/v1/auth/register"
body := map[string]string{
"partner_id": partnerID,
"partner_token": partnerToken,
"provider_name": providerName,
"provider_org_id": providerOrgID,
"provider_specialty": "CARDIOLOGY",
}
if providerID != "" {
body["provider_id"] = providerID
}
raw, _ := json.Marshal(body)
resp, err := http.Post(url, "application/json", bytes.NewReader(raw))
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusConflict {
b, _ := io.ReadAll(resp.Body)
return newSukiAPIError(resp.StatusCode, url, string(b))
}
return nil
}
func loginWithRegisterFallback(partnerID, partnerToken, providerID, providerName, providerOrgID string) (string, error) {
token, err := login(partnerID, partnerToken, providerID)
if err == nil {
return token, nil
}
var apiErr *SukiApiError
if errors.As(err, &apiErr) && apiErr.Matches("provider_not_registered") {
fmt.Println(hintForError(apiErr))
if err := registerProvider(partnerID, partnerToken, providerName, providerOrgID, providerID); err != nil {
return "", err
}
return login(partnerID, partnerToken, providerID)
}
if errors.As(err, &apiErr) {
fmt.Println(hintForError(apiErr))
}
return "", err
}
func doJSON(method, url, sukiToken string, payload any, wantStatus int, dest any) error {
var reader io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
return err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, url, reader)
if err != nil {
return err
}
req.Header = restHeaders(sukiToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode != wantStatus {
return newSukiAPIError(resp.StatusCode, url, string(b))
}
if dest == nil {
return nil
}
return json.Unmarshal(b, dest)
}
// Ambient session setup.
func createAmbientSession(sukiToken string, body map[string]string) (string, error) {
// Create an Ambient session and return ambient_session_id (HTTP 201).
if body == nil {
body = map[string]string{}
}
var out createSessionResponse
err := doJSON(http.MethodPost, baseURL+"/api/v1/ambient/session/create", sukiToken, body, http.StatusCreated, &out)
if err != nil {
return "", err
}
if out.AmbientSessionID == "" {
return "", &SukiApiError{
HTTPStatus: http.StatusCreated,
URL: baseURL + "/api/v1/ambient/session/create",
Message: "create response missing ambient_session_id",
}
}
return out.AmbientSessionID, nil
}
func seedContext(sukiToken, ambientSessionID string) error {
// Add initial provider, patient, and visit context before streaming.
payload := map[string]any{
"provider": map[string]string{"specialty": "CARDIOLOGY", "provider_role": "ATTENDING"},
"patient": map[string]string{"dob": "2000-01-01", "sex": "male"},
"visit": map[string]string{
"chief_complaint": "Headache",
"encounter_type": "AMBULATORY",
"reason_for_visit": "Follow-up for migraines",
"visit_type": "ESTABLISHED_PATIENT",
},
"sections": []map[string]string{{"loinc": "10164-2"}, {"loinc": "48765-2"}},
"diagnoses": map[string]any{
"values": []map[string]any{
{
"codes": []map[string]string{{
"code": "I10", "description": "Essential hypertension", "type": "ICD10", // Example diagnosis only
}},
"diagnosis_note": "Hypertension", // Example only
},
},
},
"emr": map[string]string{"target_emr": "EPIC"},
}
return doJSON(
http.MethodPost,
fmt.Sprintf("%s/api/v1/ambient/session/%s/context", baseURL, ambientSessionID),
sukiToken,
payload,
http.StatusOK,
nil,
)
}
func stripWavHeader(raw []byte) []byte {
// If the file is WAV, drop the 44-byte header and keep only raw PCM bytes.
if len(raw) >= 12 && string(raw[:4]) == "RIFF" && string(raw[8:12]) == "WAVE" {
if len(raw) > wavHeaderBytes {
return raw[wavHeaderBytes:]
}
}
return raw
}
func sendJSON(conn *websocket.Conn, obj map[string]any) error {
return conn.WriteJSON(obj)
}
func sendEvent(conn *websocket.Conn, event string) error {
return sendJSON(conn, map[string]any{"type": "EVENT", "event": event})
}
// WebSocket streaming (Go style: auth headers, not Sec-WebSocket-Protocol).
// Open /ws/stream, send START_TIME, stream LINEAR16 PCM chunks, then end with RU9G.
func streamPCMFile(sukiToken, ambientSessionID, path string) error {
hdr := http.Header{}
hdr.Set("sdp_suki_token", sukiToken)
hdr.Set("ambient_session_id", ambientSessionID)
if sdpProviderID != "" {
hdr.Set("sdp_provider_id", sdpProviderID)
}
conn, resp, err := websocket.DefaultDialer.Dial(wsURL, hdr)
if err != nil {
if resp != nil {
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return fmt.Errorf("WebSocket connect failed: %w; body=%s", err, truncate(string(b), 500))
}
return fmt.Errorf("WebSocket connect failed: %w", err)
}
defer conn.Close()
// Mark the start of this audio segment (RFC3339 timestamp, base64-encoded).
rfc3339 := time.Now().UTC().Format("2006-01-02T15:04:05Z")
if err := sendJSON(conn, map[string]any{
"type": "START_TIME",
"data": b64([]byte(rfc3339)),
}); err != nil {
return fmt.Errorf("WebSocket send START_TIME failed: %w", err)
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
pcm := stripWavHeader(raw)
// Send ~100 ms JSON AUDIO frames (base64 PCM). Do not send binary WebSocket frames.
for i := 0; i < len(pcm); i += chunkBytes {
end := i + chunkBytes
if end > len(pcm) {
end = len(pcm)
}
if err := sendJSON(conn, map[string]any{
"type": "AUDIO",
"data": b64(pcm[i:end]),
}); err != nil {
return fmt.Errorf("WebSocket send AUDIO failed: %w", err)
}
}
// Optional controls while streaming:
// sendEvent(conn, "PAUSE"); sendEvent(conn, "KEEP_ALIVE"); sendEvent(conn, "RESUME")
// sendEvent(conn, "CANCEL") // ABORT is deprecated and is not handled by the stream handler
if err := sendJSON(conn, map[string]any{"type": "AUDIO", "data": "RU9G"}); err != nil {
return fmt.Errorf("WebSocket send RU9G failed: %w", err)
}
return nil
}
// End session and fetch results over REST.
func endAmbientSession(sukiToken, ambientSessionID string) error {
return doJSON(
http.MethodPost,
fmt.Sprintf("%s/api/v1/ambient/session/%s/end", baseURL, ambientSessionID),
sukiToken,
nil,
http.StatusOK,
nil,
)
}
func getStatus(sukiToken, ambientSessionID string) (string, error) {
var out statusResponse
err := doJSON(
http.MethodGet,
fmt.Sprintf("%s/api/v1/ambient/session/%s/status", baseURL, ambientSessionID),
sukiToken,
nil,
http.StatusOK,
&out,
)
return out.Status, err
}
func waitForResults(sukiToken, ambientSessionID string) (map[string]any, error) {
// Poll status until terminal. On completed, fetch transcript + content.
for {
status, err := getStatus(sukiToken, ambientSessionID)
if err != nil {
return nil, err
}
switch status {
case "completed":
var transcript any
var content any
if err := doJSON(http.MethodGet,
fmt.Sprintf("%s/api/v1/ambient/session/%s/transcript", baseURL, ambientSessionID),
sukiToken, nil, http.StatusOK, &transcript); err != nil {
return nil, err
}
if err := doJSON(http.MethodGet,
fmt.Sprintf("%s/api/v1/ambient/session/%s/content?cumulative=false", baseURL, ambientSessionID),
sukiToken, nil, http.StatusOK, &content); err != nil {
return nil, err
}
return map[string]any{"status": status, "transcript": transcript, "content": content}, nil
case "failed", "skipped", "aborted":
return map[string]any{"status": status, "transcript": nil, "content": nil}, nil
}
time.Sleep(2 * time.Second)
}
}
func main() {
// Full Ambient path: auth → create → context → stream → end → poll results.
sukiToken, err := loginWithRegisterFallback(partnerID, partnerToken, providerID, "Dr. John Smith", "org-123") // Example provider name / org only
if err != nil {
var apiErr *SukiApiError
if errors.As(err, &apiErr) {
fmt.Println(hintForError(apiErr))
}
panic(err)
}
ambientSessionID, err := createAmbientSession(sukiToken, map[string]string{})
if err != nil {
var apiErr *SukiApiError
if errors.As(err, &apiErr) {
fmt.Println(hintForError(apiErr))
}
panic(err)
}
if err := seedContext(sukiToken, ambientSessionID); err != nil {
var apiErr *SukiApiError
if errors.As(err, &apiErr) {
fmt.Println(hintForError(apiErr))
}
panic(err)
}
// Point path at your LINEAR16 PCM or WAV file (example filename: audio.wav).
if err := streamPCMFile(sukiToken, ambientSessionID, "audio.wav"); err != nil {
var apiErr *SukiApiError
if errors.As(err, &apiErr) {
fmt.Println(hintForError(apiErr))
}
panic(err)
}
if err := endAmbientSession(sukiToken, ambientSessionID); err != nil {
var apiErr *SukiApiError
if errors.As(err, &apiErr) {
fmt.Println(hintForError(apiErr))
}
panic(err)
}
results, err := waitForResults(sukiToken, ambientSessionID)
if err != nil {
var apiErr *SukiApiError
if errors.As(err, &apiErr) {
fmt.Println(hintForError(apiErr))
}
panic(err)
}
fmt.Printf("%v\n", results)
}
Troubleshooting
Login Fails with `provider_not_registered`
Login Fails with `provider_not_registered`
Register the provider once, then log in again.
WebSocket Closes During a Pause
WebSocket Closes During a Pause
Send
KEEP_ALIVE every five seconds while paused. While streaming, send audio at least every 25 seconds so the server does not close the connection.Invalid `sdp_suki_token` or Auth Failures on the Stream
Invalid `sdp_suki_token` or Auth Failures on the Stream
Call login again and send the new
suki_token as sdp_suki_token, or include it in the browser protocol list. See API errors for related codes.Audio Rejected or Empty Results
Audio Rejected or Empty Results
Confirm the audio is LINEAR16 PCM. If you start from a WAV file, strip the 44-byte header before streaming. Do not send binary WebSocket frames on this endpoint.
Summary
In this tutorial, you:- Authenticated with the Suki API.
- Created an ambient session and seeded the session context.
- Streamed LINEAR16 PCM audio over a WebSocket connection.
- Ended the session and retrieved the generated transcript and clinical content.
- API:
https://sdp.suki.ai - WebSocket:
wss://sdp.suki.ai/ws/stream