OHMOHM Studio

Cookbook: build a Visit feature in your EMR

The canonical hospital integration — record consult, get structured JSON, save into your own DB.

View as Markdown

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:

  1. Design the API in Studio — one-time, ~5 minutes
  2. Add the SDK to your EMR — one-time, ~10 minutes
  3. 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

TabWhat to do
BuilderDrop 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).
PromptEdit 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."
InputsDeclare 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.
SettingsSet 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 keyLive 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-extract

returning a JSON object shaped exactly the way you designed it.


Phase 2 — Add the SDK to your EMR

npm install @ohm_studio/sdk
server/ohm.ts
import { 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:

app/actions/visit.ts
"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.

components/VisitRecorder.tsx
"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):

app/layout.tsx
"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-handling

If 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.

components/VisitRecorder.tsx
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

ConcernHospital ownsOHM 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)sharedshared

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:

  • Streamingaudio.extractStream halves perceived latency by showing the transcript first.
  • Webhooks — register a URL to receive invocation.success / invocation.failed events; verify HMAC-SHA256 on OHM-Signature header.
  • 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

PhaseTimeOwner
Design API in Studio (clone, customise, publish)30-45 minTech lead + clinical lead pair
SDK install + env wiring5 minBackend dev
Visit screen UI (button + state)30 minFrontend dev
Server action / route handler30 minBackend dev
Wire to your DB schema30 minBackend dev
Test with 10 real consults1 hourClinical 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.