OHM RN SDK (React Native)
@ohm_studio/sdk-react-native — OHM Studio in your mobile app.
Short name:
OHM RN SDK· Full npm name:@ohm_studio/sdk-react-native
Same API as the OHM SDK (@ohm_studio/sdk), with two RN-specific adaptations:
- File shape:
{ uri, name, type }instead of Blob. - Bundle-key safeguard:
ohms_live_*keys are blocked unless you opt in.
Install
npm install @ohm_studio/sdk-react-nativeInitialise
import { OHM } from "@ohm_studio/sdk-react-native";
// OHM is hospital-deployed. Each hospital exposes its own API URL — set
// baseUrl to the hospital your app integrates with. `api.ohm.doctor` is
// OHM's own demo hospital; use it only for evaluation.
const ohm = new OHM({
apiKey: TEST_KEY, // ohms_test_*
baseUrl: "https://api.<hospital>.example", // your hospital
acknowledgeBundledKey: true, // dev-only override
});Recording audio
The SDK ships a clinical-grade ExpoRecorder and a useRecorder() hook
for the Expo flow. Bare-RN apps can use any recorder library — the SDK
only needs { uri, name, type }.
import { Pressable, Text, View } from "react-native";
import { Audio } from "expo-av";
import { OHM } from "@ohm_studio/sdk-react-native";
import { OhmProvider, useRecorder } from "@ohm_studio/sdk-react-native/react";
const ohm = new OHM({ apiKey: TEST_KEY, acknowledgeBundledKey: true });
export default function App() {
return (
<OhmProvider client={ohm}>
<Visit />
</OhmProvider>
);
}
function Visit() {
const r = useRecorder({
audio: Audio, // pass the Expo module in
apiSlug: "opd-clinic", // auto-extracts on stop
silenceAutoStop: { ms: 6000 },
maxDurationMs: 15 * 60_000,
});
return (
<View>
<Pressable onPress={r.isRecording ? r.stop : r.start}>
<Text>
{r.extracting ? "Extracting…" :
r.isRecording ? `Stop (${r.durationSec.toFixed(0)}s)` :
"Record"}
</Text>
</Pressable>
{r.transcript && <Text>{r.transcript}</Text>}
</View>
);
}import { Audio } from "expo-av";
import { ExpoRecorder } from "@ohm_studio/sdk-react-native";
const rec = new ExpoRecorder(Audio, {
silenceAutoStop: { ms: 6000 },
maxDurationMs: 15 * 60_000,
onLevel: setVu,
onError: (e) => toast(e.message),
// optional — wire to expo-keep-awake if you want it:
keepAwake: {
activate: () => activateKeepAwake(),
deactivate: () => deactivateKeepAwake(),
},
});
await rec.start();
const file = await rec.stop(); // → { uri, name, type }
const { data } = await ohm.audio.extract({ apiSlug: "opd-clinic", file });The default recording preset is 16 kHz mono AAC at 32 kbps — tuned
for clinical speech and small file size. Override via recordingOptions:
if you need something else. The recorder also configures the iOS audio
session so recording works in silent mode.
expo-audio replaces expo-av from Expo SDK 54 onwards. The API
shifted enough (class-based AudioRecorder instead of static
Audio.Recording) that we don't shim both — instead, drive it
directly and hand the resulting URI to ohm.audio.extract:
import { useAudioRecorder, RecordingPresets, setAudioModeAsync }
from "expo-audio";
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
async function start() {
await setAudioModeAsync({
allowsRecording: true,
playsInSilentMode: true,
});
await recorder.prepare();
recorder.record();
}
async function stopAndExtract() {
await recorder.stop();
const uri = recorder.uri!;
const { data, transcript } = await ohm.audio.extract({
apiSlug: "opd-clinic",
file: { uri, name: "rec.m4a", type: "audio/mp4" },
});
}The OHM SDK only needs { uri, name, type }; it doesn't care which
recorder lib produced the file. You lose the SDK's silence-auto-stop
and typed errors when going this route — wire your own from
recorder.getStatus() if you need them.
For bare React Native (no Expo), pair react-native-audio-recorder-player
with our BareRecorder class — same lifecycle and error model as
ExpoRecorder:
import AudioRecorderPlayer from "react-native-audio-recorder-player";
import { BareRecorder } from "@ohm_studio/sdk-react-native";
const rec = new BareRecorder(AudioRecorderPlayer, {
silenceAutoStop: { ms: 6000 },
maxDurationMs: 15 * 60_000,
onLevel: setVu,
});
await rec.start();
const file = await rec.stop();
const { data } = await ohm.audio.extract({ apiSlug: "opd-clinic", file });BareRecorder configures the native module with a clinical preset
(16 kHz mono AAC at 32 kbps via Android's VOICE_RECOGNITION source
to attenuate noise) and emits the same RecorderError codes as
ExpoRecorder.
Recorder reference
ExpoRecorder and useRecorder() mirror the browser Recorder —
same options, same lifecycle, same error codes. Mobile-specific notes:
| Concern | Behaviour |
|---|---|
| iOS silent mode | Audio session set to playsInSilentModeIOS: true, allowsRecordingIOS: true automatically. Toggle off with configureIosAudioSession: false. |
| Metering | Expo emits metering in dBFS; the SDK linearises to 0–1 for onLevel / r.level. |
| Pause / resume | Supported on Expo SDK 50+. Older SDKs throw RecorderError("NotSupported"). |
| Keep-awake | Not bundled. Pass { activate, deactivate } from expo-keep-awake if you want long consults to keep the screen on. |
| Background recording | Not enabled — staysActiveInBackground: false. Hospitals using a kiosk app can override. |
| Mic permission | requestPermissionsAsync runs on start(); preflight via rec.probePermission(). |
| Audio interruptions (incoming call) | interruptionModeIOS: DoNotMix so a call pauses the recording instead of mixing audio. |
Typed errors
import { RecorderError } from "@ohm_studio/sdk-react-native";
try { await rec.start() }
catch (e) {
if (e instanceof RecorderError) {
if (e.code === "PermissionDenied") openSettings();
if (e.code === "Interrupted") showRetryUI();
if (e.code === "NotSupported") fallbackToBareRN();
}
}For SDK-call errors (extract / audio.extract / jobs.* / etc.) RN uses the same hierarchy as the JS SDK:
import {
OHMAbortError,
OHMTimeoutError,
OHMNetworkError, // pair with OhmQueue for offline replay
OHMNotFoundError,
OHMRateLimitError,
OHMValidationError,
OHMAuthError,
OHMServerError,
OHM_ERROR_CODES,
} from "@ohm_studio/sdk-react-native";OHMNetworkError is the canonical "offline → queue locally" signal —
spotty hospital wifi makes this the most common failure mode in
production. The pattern:
try {
await ohm.audio.extract({ apiSlug, file, ... });
} catch (e) {
if (e instanceof OHMNetworkError) {
await queue.enqueue("audio.extract", { apiSlug, file, ... });
toast("Saved offline — will send when connected");
}
}See the JavaScript SDK errors reference for
the full class catalogue + OHM_ERROR_CODES constants.
Other React hooks
import {
OhmProvider,
useOhmAudioExtract,
useOhmExtract,
useOhmSummarize,
} from "@ohm_studio/sdk-react-native/react";
function ManualUploader() {
const { mutateAsync, data, isPending } = useOhmAudioExtract({
apiSlug: "opd-clinic",
});
// call mutateAsync({ file: { uri, name, type } }) yourself
}Sync vs streaming vs async — all three work in RN
The React Native SDK exposes the same three extraction modes as the JS SDK. Pick the one that fits your audio length + UX:
// Sync — best for ≤ 10 min audio, tight UX
const r = await ohm.audio.extract({
apiSlug: "opd-clinic",
file: { uri, name: "rec.m4a", type: "audio/mp4" },
});
// Streaming — best for 10–30 min, transcript shows up first
const stream = ohm.audio.extractStream({ apiSlug, file: rnFile });
for await (const chunk of stream) {
if (chunk.type === "transcript") setTranscript(chunk.transcript);
if (chunk.type === "data") setData(chunk.data);
}
// Async — best for 30 min+, mobile-background, hour-long recordings
const { jobId } = await ohm.audio.jobs.create({
apiSlug: "long-consult",
file: rnFile,
webhookUrl: "https://your-backend/ohm-callback", // optional
});
const final = await ohm.audio.jobs.poll(jobId, {
intervalMs: 3000,
onProgress: (j) => setProgress(j.workerProgress),
});
// One-line submit-and-await
const final = await ohm.audio.extractAsync({
apiSlug,
file: rnFile,
intervalMs: 3000,
onProgress: (j) => setProgress(j.workerProgress),
});Same audit fields (patientHash, recordedById,
idempotencyKey) work identically across all three modes.
Same useRecorder hook drives all three — pass apiSlug to
auto-extract sync, or call r.stop() to get the file and submit it
yourself via any mode:
const r = useRecorder({ audio: Audio });
const onStop = async () => {
const file = await r.stop();
if (!file) return;
// Pick your mode based on the recording length:
if (r.durationSec < 600) {
await ohm.audio.extract({ apiSlug, file }); // sync
} else {
await ohm.audio.jobs.create({ // async
apiSlug,
file,
webhookUrl: "https://your-backend/ohm-callback",
});
}
};For the full async-extraction reference with webhook receiver template, see Async extraction.
Offline queue — OhmQueue
Hospital basements / OT lifts / ICU corridors lose network mid-call.
OhmQueue persists failed extractions to AsyncStorage and replays
them when connectivity returns. Bring your own storage adapter
(usually @react-native-async-storage/async-storage) so the SDK
doesn't bundle a peer dep:
import {
OhmQueue,
makeAsyncStorageAdapter,
OHMServerError,
} from "@ohm_studio/sdk-react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
const queue = new OhmQueue({
storage: makeAsyncStorageAdapter(AsyncStorage),
client: ohm,
});
// On a failed call, enqueue:
try {
await ohm.audio.extract({ apiSlug, file, patientHash });
} catch (e) {
if (e instanceof OHMServerError && e.status === 0) {
await queue.enqueue("audio.extract", { apiSlug, file, patientHash });
toast("Queued for retry — will send when online");
}
}
// On reconnect (use NetInfo to detect):
const { replayed, failed, skipped } = await queue.flush();flush() is sequential — stops on first auth error so a permanent
401 doesn't burn the entire queue. Each entry retries up to 5 times
before being skipped. Surface a "X uploads pending" badge using
await queue.list() for end-user transparency.
PHI redaction — restoreTokens
When an API has Redact PHI before extraction enabled, the server
returns typed tokens ([PATIENT_1], [MRN_1]) instead of patient
identifiers. Restore originals client-side:
import { restoreTokens } from "@ohm_studio/sdk-react-native";
const result = await ohm.extract({ apiSlug, text });
if (result.phiTokenMap) {
const restored = restoreTokens(result.data, result.phiTokenMap);
form.setValues(restored);
}Discovery & audit
Same apis.list(), apis.get(slug), invocations.searchByPatient(...)
surfaces as the JS SDK. See the JavaScript SDK reference
for usage — identical signatures.
Never bundle live keys
Read API key handling before shipping. The proxy pattern is the only safe way to use a live key from a mobile app.