Cookbook: build a Visit feature in your EMR
The canonical hospital integration — record consult, get structured JSON, save into your own DB.
The most common reason hospitals adopt OHM: their EMR already has a Visits table, Doctors table, and a way for clinicians to log in. They want to add voice-to-structured-note without rebuilding their stack.
This recipe walks through exactly that, in three phases:
- Design the API in Studio — one-time, ~5 minutes
- Add the SDK to your EMR — one-time, ~10 minutes
- Wire the visit screen — ~30 lines of code
Total integration time: under an hour for a real EMR codebase.
Phase 1 — Design your visit API in Studio
Sign in + project
Open studio.ohm.doctor with your OHM org admin
credentials. Click + New project → name it after your product
(MyHospital EMR). The project groups all APIs and keys for this product.
Clone the OPD template
+ New API → starter dropdown → OPD Prescription (or pick any of the
26 specialty starters — psychiatry consult, discharge summary, ICU daily,
emergency triage, etc.). Give it a slug (visit-extract) and click Create.
The Builder tab opens with the cloned schema. You're now editing a working clinical note template.
Customise to your hospital's shape
| Tab | What to do |
|---|---|
| Builder | Drop sections you don't capture (e.g. delete referrals if your EMR has its own referral flow). Add fields you do (e.g. a consentTaken boolean). |
| Prompt | Edit the system prompt to add hospital context: "You are documenting an OPD visit at MyHospital. Use Indian drug brand names where the doctor mentions one." |
| Inputs | Declare HTTP-time inputs your callers will pass — patientId: string, doctorId: string, visitId: string. These slot into {{patientId}} etc. in your prompt template. |
| Insights (optional) | Toggle on if you want a parallel pass for things like risk stratification or scoring. Skip for a basic visit. |
| Settings | Set a per-API rate limit (e.g. 30 RPM if you have 5 concurrent doctors). |
Test in the playground
Playground tab → paste a sample transcript or click Record to try voice-to-text → click Run extraction. Iterate on the prompt + schema until the JSON matches what you want to save.
Save the working transcript as a regression fixture (the Saved tests card above) so you can re-run it after every prompt change.
Publish + mint a key
Click Publish in the top bar. The status pill flips to PUBLISHED — your API is now callable.
Keys page → + New key → Live mode → copy the ohms_live_… value.
Live keys stay on your backend (never ship in mobile bundles — see RN key
handling).
That's the entire Studio side. You've now got a callable endpoint at:
POST https://api.ohm.doctor/api/studio/v1/audio/extract/visit-extractreturning a JSON object shaped exactly the way you designed it.
Phase 2 — Add the SDK to your EMR
npm install @ohm_studio/sdkimport { OHM } from "@ohm_studio/sdk";
export const ohm = new OHM(process.env.OHM_API_KEY!);Drop your live key into env (never commit it):
# .env
OHM_API_KEY=ohms_live_...That's all the setup. Use ohm from any backend route or server action.
Phase 3 — The visit screen
The whole integration is two files:
"use server";
import { ohm } from "@/server/ohm";
import { db } from "@/server/db";
import { auth } from "@/server/auth";
export async function recordAndExtract(formData: FormData) {
const session = await auth(); // your existing auth
const file = formData.get("audio") as Blob;
const visitId = formData.get("visitId") as string;
// 1. OHM does transcription + structured extraction in one call.
const { transcript, data } = await ohm.audio.extract({
apiSlug: "visit-extract",
file,
inputs: {
visitId,
doctorId: session.user.id,
patientId: (await db.visit.findUnique({ where: { id: visitId } }))!.patientId,
},
});
// 2. Save into YOUR DB. OHM never persists this; the only thing OHM kept
// is a metadata row (latency, status, token count) for the audit log.
await db.visit.update({
where: { id: visitId, doctorId: session.user.id },
data: {
transcript,
vitals: data.vitals,
diagnoses: data.diagnoses,
medications: data.medications,
assessment: data.assessment,
plan: data.plan,
// ...whatever fields you declared in the Studio Builder tab
structuredAt: new Date(),
},
});
}With the useRecorder hook, the entire visit recorder is one component, no
state plumbing, no MediaRecorder boilerplate — codec selection, mic permission,
VU level, duration, silence auto-stop, wake-lock, and the audio.extract call
are all handled inside the hook.
"use client";
import { useRecorder } from "@ohm_studio/sdk/react";
export function VisitRecorder({ visitId }: { visitId: string }) {
const r = useRecorder({
apiSlug: "visit-extract", // auto-extract on stop
extractInputs: { visitId },
silenceAutoStop: { ms: 6000 }, // stop after 6s of silence
maxDurationMs: 15 * 60_000, // hard cap at 15 min
wakeLock: true, // keep tablet screen awake
});
return (
<div className="space-y-2">
<button
onClick={r.isRecording ? r.stop : r.start}
disabled={r.extracting}
className={`px-6 py-3 rounded-lg text-white ${
r.extracting ? "bg-gray-500" :
r.isRecording ? "bg-red-600" : "bg-emerald-700"
}`}
>
{r.extracting ? "Extracting…" :
r.isRecording ? `Stop (${r.durationSec.toFixed(0)}s)` :
"Record consult"}
</button>
{/* simple VU meter */}
{r.isRecording && (
<div className="h-2 bg-gray-200 rounded">
<div
className="h-full bg-emerald-500 rounded transition-all"
style={{ width: `${Math.min(100, r.level * 400)}%` }}
/>
</div>
)}
{r.transcript && <pre className="text-sm">{r.transcript}</pre>}
{r.error && <p className="text-red-600">{r.error.message}</p>}
</div>
);
}The hook needs a single <OhmProvider client={ohm}> somewhere above it
(usually in your root layout):
"use client";
import { OHM } from "@ohm_studio/sdk";
import { OhmProvider } from "@ohm_studio/sdk/react";
const ohm = new OHM(process.env.NEXT_PUBLIC_OHM_TEST_KEY!); // test mode in browser
// in production, use a server proxy — see /security/rn-key-handlingIf you'd rather keep the server-side recordAndExtract action shown above
and just need a Recorder, drop apiSlug from useRecorder({ ... }). On
stop you get the Blob back via r.blob — POST it to your server action
yourself.
For mobile EMRs, proxy through your backend — never bundle a live key in an app store binary. See RN key handling.
import { ExpoRecorder, OHM } from "@ohm_studio/sdk-react-native";
import { Audio } from "expo-av";
// Test-mode key for local dev. In production, hit your own backend.
const ohm = new OHM({
apiKey: TEST_KEY,
acknowledgeBundledKey: true,
});
export async function recordVisit(visitId: string, doctorId: string) {
const recorder = new ExpoRecorder(Audio);
await recorder.start();
// …show "Recording" UI; user taps Stop…
const file = await recorder.stop();
const { transcript, data } = await ohm.audio.extract({
apiSlug: "visit-extract",
file,
inputs: { visitId, doctorId },
});
// Send to YOUR backend to persist
await fetch("https://your-emr.com/api/visits/" + visitId, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + sessionToken,
},
body: JSON.stringify({ transcript, ...data }),
});
}That's the entire integration — under 50 lines of glue code.
What lives where
| Concern | Hospital owns | OHM owns |
|---|---|---|
| Patient table, Visit table, your EMR schema | ✅ | |
| Doctor login + sessions | ✅ | |
| Visit form UI, save button, print template | ✅ | |
Audio capture (or use OHM's Recorder helper) | ✅ | |
| Transcription (23-language clinical STT) | ✅ | |
| Schema-aware extraction (your declared fields) | ✅ | |
| Clinical priors (vital sanity, negation, code-mix) | ✅ | |
| Foundation block, prompt versioning, audit log of every call | ✅ | |
FHIR shape mapping (your data is already FHIR-shaped) | shared | shared |
OHM never persists your patient data
The only thing OHM keeps is a metadata row per call — latency, token count, status, the API key prefix. Optional payload retention is off by default; flip it on in Settings if you want OHM to keep the input/output for medico-legal review.
Production-grade follow-ups
Once the basic integration works, layer on:
- Streaming —
audio.extractStreamhalves perceived latency by showing the transcript first. - Webhooks — register a URL to receive
invocation.success/invocation.failedevents; verify HMAC-SHA256 onOHM-Signatureheader. - Saved tests — every regression-causing transcript becomes a fixture; re-run all tests after a prompt change.
- Rollback — every Publish snapshots the spec; one-click rollback if a new prompt extracts worse than the previous one.
- Scale tuning — what to do once you cross 50 RPS sustained.
Time budget for a real hospital EMR
| Phase | Time | Owner |
|---|---|---|
| Design API in Studio (clone, customise, publish) | 30-45 min | Tech lead + clinical lead pair |
| SDK install + env wiring | 5 min | Backend dev |
| Visit screen UI (button + state) | 30 min | Frontend dev |
| Server action / route handler | 30 min | Backend dev |
| Wire to your DB schema | 30 min | Backend dev |
| Test with 10 real consults | 1 hour | Clinical reviewer |
| Total | ~3 hours |
That's a working voice-to-EMR feature ready for clinical pilot. Add hardening (retries, error toasts, save-as-draft) over the next day or two.