AWS Transcribe · 2026-11-05 · Transcribe arc

AWS Transcribe APIs for MCP Servers: Batch Jobs, Real-Time Streaming, Custom Vocabulary, and Medical Transcription

AliveMCP probes every registered MCP endpoint every 60 seconds — and the speech-related failure patterns we observe cluster around four Transcribe integration mistakes that every MCP builder makes at least once: a batch transcription tool that submits a StartTranscriptionJob and immediately blocks the handler waiting for the result, only to time out because Transcribe batch jobs take anywhere from 15 seconds to 20 minutes depending on audio length; a streaming transcription session that passes IdentifyLanguage: true to StartStreamingTranscriptionCommand (which is a valid batch-job parameter) but receives a BadRequestException on the very first audio frame because streaming transcription requires a fixed LanguageCode and does not support auto-detection; a custom vocabulary update that calls UpdateVocabularyCommand and immediately starts a transcription job referencing the same vocabulary by name, only to silently fall back to the base model because the vocabulary re-entered PENDING state during the update and the job started before it reached READY again; and a medical transcription job that uses Type: 'DICTATION' for a two-person patient-provider conversation, which causes the model to treat both speakers as one and concatenate their words into a single stream with no attribution — a transcript that is technically correct word-by-word but structurally unreadable. Four Transcribe APIs form the complete speech intelligence toolkit for MCP servers. Batch transcription converts S3-stored audio files into structured JSON transcripts asynchronously — the right choice for files already stored in S3, meeting recordings, podcast episodes, and any audio where the agent can tolerate a 30-second-to-20-minute wait for a complete, high-quality transcript; the key design decisions are always setting OutputBucketName (omitting it produces an expiring AWS-managed pre-signed URL), choosing between a fixed LanguageCode and auto-detection via IdentifyLanguage: true with a LanguageOptions narrowing list, and understanding the results.items[] structure where pronunciation entries carry timestamps and speaker labels while punctuation entries carry neither. Streaming transcription converts live audio into text within 300–600 ms using a bidirectional HTTP/2 stream from the separate @aws-sdk/client-transcribe-streaming package — the right choice for real-time voice interfaces, live call analysis, and agent voice pipelines; the key design decisions are understanding that each Result in the stream carries a ResultId that persists across revisions as the model refines its hypothesis, and that you must key your accumulator on ResultId and only emit when IsPartial: false. Custom vocabularies and vocabulary filters improve recognition of domain-specific terms and control word-level transcript output — vocabularies require a compile phase (1–5 minutes to reach READY state) while filters are available immediately, and UpdateVocabularyCommand is a destructive full replacement that re-enters PENDING state rather than a patch. Medical transcription uses a speech model fine-tuned on clinical audio and adds PHI identification, specialty-specific vocabulary, and streaming support — but requires a Specialty parameter, only supports US English, uses a completely separate vocabulary API (CreateMedicalVocabularyCommand), and must only run under a signed AWS Business Associate Addendum. This guide synthesizes all four Transcribe patterns into a complete audio intelligence stack for MCP server builders.

TL;DR

Pattern 1: Batch transcription — asynchronous job lifecycle, transcript JSON, speaker diarization, and PII redaction

When an AI agent needs to transcribe an audio file stored in S3 — a recorded meeting, an uploaded podcast episode, a support call from earlier today — batch transcription is the right choice. The API converts the file asynchronously in the background, letting the job run while the MCP server does other work. The critical design constraint most builders discover in production is that the tool handler must not block waiting for the job: a job on a 90-minute audio file runs for several minutes, and any HTTP timeout in the MCP transport layer will cut the connection before the transcript arrives. The correct pattern is to submit the job and return the job ID, then expose a second tool that the agent calls to check status and retrieve the result.

Job submission and polling

Three parameters determine job routing and must be set intentionally on every call:

import {
  TranscribeClient,
  StartTranscriptionJobCommand,
  GetTranscriptionJobCommand,
  TranscriptionJobStatus,
} from '@aws-sdk/client-transcribe';
import { S3Client, GetObjectCommand } from '@aws-sdk/client-s3';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';

const transcribe = new TranscribeClient({ region: 'us-east-1' });
const s3 = new S3Client({ region: 'us-east-1' });
const server = new McpServer({ name: 'transcribe-tools', version: '1.0.0' });

// Tool 1: submit the job and return immediately
server.tool('transcribe_audio_submit', {
  s3_uri: z.string().regex(/^s3:\/\//).describe('s3://bucket/key — audio file to transcribe'),
  output_bucket: z.string().describe('S3 bucket for transcript output'),
  language_code: z.string().optional().describe('BCP-47 e.g. en-US. Omit to auto-detect.'),
  language_hints: z.array(z.string()).optional()
    .describe('Narrow auto-detection to these BCP-47 codes — reduces cost and improves accuracy'),
  show_speakers: z.boolean().default(false),
  max_speakers: z.number().int().min(2).max(10).default(2),
  redact_pii: z.boolean().default(false),
}, async ({ s3_uri, output_bucket, language_code, language_hints, show_speakers, max_speakers, redact_pii }) => {
  const jobName = `mcp-transcribe-${Date.now()}`;

  await transcribe.send(new StartTranscriptionJobCommand({
    TranscriptionJobName: jobName,
    Media: { MediaFileUri: s3_uri },
    // Omit MediaFormat — Transcribe auto-detects from the file extension
    ...(language_code
      ? { LanguageCode: language_code }
      : {
          IdentifyLanguage: true,
          // Always narrow the candidate set — testing all supported languages
          // increases cost and job duration with no accuracy benefit
          ...(language_hints?.length ? { LanguageOptions: language_hints } : {}),
        }
    ),
    OutputBucketName: output_bucket,
    OutputKey: `transcripts/${jobName}.json`,
    Settings: {
      ...(show_speakers ? { ShowSpeakerLabels: true, MaxSpeakerLabels: max_speakers } : {}),
    },
    ...(redact_pii ? {
      ContentRedaction: {
        RedactionType: 'PII',
        RedactionOutput: 'redacted_and_unredacted',
        // Explicit list prevents silent changes when AWS adds new PII category defaults
        PiiEntityTypes: ['NAME', 'PHONE', 'SSN', 'CREDIT_DEBIT_NUMBER', 'ADDRESS', 'EMAIL'],
      },
    } : {}),
  }));

  return { content: [{ type: 'text', text: JSON.stringify({ job_name: jobName, status: 'QUEUED' }) }] };
});

// Tool 2: check status and retrieve result when COMPLETED
server.tool('transcribe_audio_result', {
  job_name: z.string(),
  output_bucket: z.string(),
  redacted: z.boolean().default(false).describe('Return the redacted transcript (requires redact_pii:true on submit)'),
}, async ({ job_name, output_bucket, redacted }) => {
  const { TranscriptionJob: job } = await transcribe.send(
    new GetTranscriptionJobCommand({ TranscriptionJobName: job_name })
  );

  if (job?.TranscriptionJobStatus !== TranscriptionJobStatus.COMPLETED) {
    return {
      content: [{ type: 'text', text: JSON.stringify({
        status: job?.TranscriptionJobStatus,
        ...(job?.TranscriptionJobStatus === TranscriptionJobStatus.FAILED
          ? { reason: job.FailureReason }
          : { message: 'Not yet complete — retry in 15–30 seconds' }),
      }) }],
    };
  }

  // For redacted_and_unredacted output, the redacted key appends nothing;
  // the unredacted key appends '-unredacted' before the .json extension
  const key = redacted
    ? `transcripts/${job_name}.json`
    : `transcripts/${job_name}.json`;
  // Note: for redacted_and_unredacted, Transcribe produces:
  //   transcripts/{jobName}.json          → redacted (PII replaced with [PII])
  //   transcripts/{jobName}-unredacted.json → original text

  const transcript = await fetchTranscriptJson(output_bucket, `transcripts/${job_name}.json`);

  return {
    content: [{
      type: 'text',
      text: JSON.stringify({
        job_name,
        language: job.LanguageCode ?? job.IdentifiedLanguageScore,
        text: transcript.results.transcripts[0].transcript,
        word_count: transcript.results.items.filter((i: any) => i.type === 'pronunciation').length,
        speaker_turns: extractSpeakerTurns(transcript.results.items),
      }, null, 2),
    }],
  };
});

async function fetchTranscriptJson(bucket: string, key: string): Promise<any> {
  const { Body } = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }));
  const chunks: Uint8Array[] = [];
  for await (const chunk of Body as AsyncIterable<Uint8Array>) chunks.push(chunk);
  return JSON.parse(Buffer.concat(chunks).toString('utf-8'));
}

function extractSpeakerTurns(items: any[]): { speaker: string; text: string }[] {
  const turns: { speaker: string; text: string }[] = [];
  let currentSpeaker = '';
  let words: string[] = [];
  for (const item of items) {
    if (item.type !== 'pronunciation') continue;
    const speaker = item.speaker_label ?? 'spk_0';
    if (speaker !== currentSpeaker) {
      if (words.length) turns.push({ speaker: currentSpeaker, text: words.join(' ') });
      currentSpeaker = speaker;
      words = [];
    }
    words.push(item.alternatives[0].content);
  }
  if (words.length) turns.push({ speaker: currentSpeaker, text: words.join(' ') });
  return turns;
}

Transcript JSON structure: items[] is the workhorse

The transcript file has two useful fields. results.transcripts[0].transcript is the complete concatenated text — fast to return to the agent but loses all timing and speaker information. results.items[] contains every word and punctuation mark individually, with per-word confidence, timestamps, and optional speaker labels:

// Two item types — handle both
for (const item of transcript.results.items) {
  if (item.type === 'pronunciation') {
    // Has: start_time (string), end_time (string), alternatives[0].confidence (string), speaker_label (optional)
    const word = item.alternatives[0].content;
    const confidence = parseFloat(item.alternatives[0].confidence);
    const speaker = item.speaker_label;              // present only if ShowSpeakerLabels: true
    const startMs = parseFloat(item.start_time) * 1000;

    // Filter out low-confidence words for clean transcripts
    if (confidence < 0.60) continue;   // likely background noise or unintelligible syllable

  } else if (item.type === 'punctuation') {
    // No timestamps — punctuation is model-inferred, not audio-aligned
    const punct = item.alternatives[0].content;    // e.g. ".", ",", "?"
  }
}

Speaker diarization design

Setting MaxSpeakerLabels too high is the most common diarization mistake: if you set it to 10 for a two-person interview, Transcribe may split one speaker into several labels when they pause or change vocal energy. Set it to the actual expected speaker count whenever known.

MaxSpeakerLabelsRecommended whenRisk if too high
2 Interview (one-to-one), customer support call Background noise or brief coughs assigned to a phantom third speaker
3–4 Panel, sales call with prospect + AE + manager Pauses or mic bleed attributed to a phantom speaker
5–10 Focus group, conference roundtable High speaker fragmentation — same person assigned multiple labels; set exactly to known speaker count

Speaker labels are not tied to identity: spk_0 is simply the dominant speaker by volume and turn length. If you need named attribution, build a speaker enrollment system using Amazon Connect Voice ID or a third-party audio embedding model, then map the transcript's spk_N labels to enrolled speakers post-transcription.

Language identification and LanguageOptions

Setting IdentifyLanguage: true without LanguageOptions causes Transcribe to test all supported languages, increasing job duration and cost. Always pass the plausible candidates for the deployment context:

// Multi-language support team (English + Spanish + French)
{
  IdentifyLanguage: true,
  LanguageOptions: ['en-US', 'en-GB', 'es-US', 'es-ES', 'fr-FR', 'fr-CA'],
}

The completed job returns LanguageCode (the detected language) and IdentifiedLanguageScore (confidence, 0–1). A score below 0.7 suggests ambiguous audio — flag these jobs for manual language verification. For audio with code-switching within a single file (a speaker alternating between two languages), use IdentifyMultipleLanguages: true instead, which segments the audio by language and applies the best model per segment.

Pattern 2: Streaming transcription — HTTP/2 EventStream, ResultId-keyed accumulator, and PartialResultsStabilization

When an AI agent needs speech-to-text in near real time — live voice commands, real-time call analysis, low-latency voice interfaces — streaming transcription converts live audio to text within 300–600 ms. It uses a completely different SDK package and client class from batch transcription, and the response is a stream of incrementally refined results rather than a single completed job output.

Package setup and the HTTP/2 transport

// npm install @aws-sdk/client-transcribe-streaming
// This is a DIFFERENT package from @aws-sdk/client-transcribe (batch)
// Do NOT share a single client across concurrent sessions

import {
  TranscribeStreamingClient,
  StartStreamingTranscriptionCommand,
} from '@aws-sdk/client-transcribe-streaming';

const streamingClient = new TranscribeStreamingClient({ region: 'us-east-1' });
// Create a new client per session — the streaming SDK uses HTTP/2 bidirectional
// streams that are session-scoped

Submitting audio and consuming results

async function* audioStream(audioBuffer: Buffer) {
  const FRAME_BYTES = 3200;  // 100 ms at 16kHz 16-bit mono
  for (let offset = 0; offset < audioBuffer.length; offset += FRAME_BYTES) {
    yield {
      AudioEvent: {
        AudioChunk: audioBuffer.subarray(offset, offset + FRAME_BYTES),
      },
    };
    await new Promise(r => setTimeout(r, 1)); // prevent flooding the stream
  }
}

const command = new StartStreamingTranscriptionCommand({
  LanguageCode: 'en-US',         // No auto-detect in streaming — LanguageCode is required
  MediaEncoding: 'pcm',          // 16-bit signed little-endian PCM
  MediaSampleRateHertz: 16000,   // Must match the actual PCM sample rate exactly
  AudioStream: audioStream(buffer),
  EnablePartialResultsStabilization: true,
  PartialResultsStability: 'high',   // 'low' | 'medium' | 'high'
  ShowSpeakerLabel: false,
});

const { TranscriptResultStream: resultStream } = await streamingClient.send(command);

The ResultId-keyed accumulator

The streaming service sends multiple events for the same utterance as the model refines its hypothesis: each event carries a ResultId that persists across revisions and an IsPartial flag. The accumulator pattern replaces the pending entry for a given ResultId each time a new partial arrives, then emits and clears the entry when IsPartial: false signals finalization:

const pendingResults = new Map<string, string>(); // resultId → current transcript text
const finalSegments: string[] = [];

for await (const event of resultStream as AsyncIterable<any>) {
  if (event.BadRequestException) {
    // Wrong MediaSampleRateHertz, unsupported language for streaming, etc.
    throw new Error(`BadRequestException: ${event.BadRequestException.Message}`);
  }
  if (event.LimitExceededException) {
    // 5 concurrent streaming sessions per region limit reached
    throw new Error('Streaming concurrency limit exceeded — retry with backoff');
  }
  if (event.InternalFailureException) {
    throw new Error(`InternalFailureException: ${event.InternalFailureException.Message}`);
  }
  if (!event.TranscriptEvent) continue;

  for (const result of event.TranscriptEvent.Transcript.Results ?? []) {
    const id: string = result.ResultId;
    const text: string = result.Alternatives?.[0]?.Transcript ?? '';

    if (result.IsPartial) {
      pendingResults.set(id, text);   // update the running draft
    } else {
      pendingResults.delete(id);      // remove the draft
      finalSegments.push(text);       // emit the finalized segment
    }
  }
}

Partial results change character-by-character as the model hears more audio context — the first 300 ms of "recognition" may read "reck" before resolving to the final word. Never store or process partial results as final; use them only for live display or UI feedback. The final segments joined with spaces give the complete transcript.

PartialResultsStabilization

PartialResultsStabilityBehaviourBest for
low Partial results update on every new word hypothesis — highest responsiveness, most volatile Live caption UIs where freshness matters more than stability
medium Balanced; partial updates at a moderate cadence General-purpose live transcription
high Partial results only change when confident — lowest volatility, stable early words flagged with Stable: true on each item AI agent pipelines that partially parse transcripts in real time — prevents spurious token-by-token changes from confusing downstream intent classifiers

When EnablePartialResultsStabilization: true, items in a partial result with Stable: true are authoritative for that word position and will not change. You can begin downstream processing on stable words before the utterance finalizes — for example, triggering an intent classifier on the first stable words of a turn. All items in a final result (IsPartial: false) are authoritative regardless of their individual Stable flag.

Audio format requirements

ParameterValuesNotes
MediaEncoding pcm, ogg-opus, flac pcm = 16-bit signed little-endian (raw PCM). Most microphone APIs produce this directly. Browser MediaRecorder produces ogg-opus.
MediaSampleRateHertz 8000–48000 Hz Use 8000 for PSTN/telephony (G.711 decoded to PCM). Use 16000 for wideband mic audio. Use 48000 for browser MediaRecorder output. A mismatch causes garbled output with no error — not an exception.
Channels Mono only Downmix stereo to mono before streaming. For multi-channel call recordings, use batch transcription with channel identification instead.
Chunk size No hard limit per chunk Practical sweet spot: 50–200 ms per chunk. Very small chunks (sub-10 ms) add HTTP/2 frame overhead; very large chunks add perceived latency.

The default concurrent streaming session limit is 5 per region. For MCP servers under production load, request a limit increase via Service Quotas before deploying. Each session persists until the audio generator is exhausted or the for-await loop exits — idle sessions still count against the quota even if no audio is being sent, so always close sessions promptly by exhausting the generator or breaking out of the for-await loop.

Pattern 3: Custom vocabulary and vocabulary filters — TSV format, compile-state guard, and destructive-update pattern

When Transcribe consistently mishears domain-specific terms — a product named "AliveMCP" that gets transcribed as "alive empty" or "alive MC P", a drug name like "levetiracetam" that the model has never encountered at scale — custom vocabularies teach the model the correct pronunciation and display form. Vocabulary filters apply the opposite operation: they intercept words in the output transcript and mask, remove, or tag them. Both are provisioned resources that live at the account level, not per-job configuration.

Creating a vocabulary: Phrases array vs TSV file

import {
  TranscribeClient,
  CreateVocabularyCommand,
  GetVocabularyCommand,
  VocabularyState,
  UpdateVocabularyCommand,
  CreateVocabularyFilterCommand,
} from '@aws-sdk/client-transcribe';

const transcribe = new TranscribeClient({ region: 'us-east-1' });

// Simple vocabulary — word list, no pronunciation hints
// Good for product names and acronyms that differ only in capitalisation
await transcribe.send(new CreateVocabularyCommand({
  VocabularyName: 'mcp-tools-vocab',
  LanguageCode: 'en-US',
  Phrases: [
    'AliveMCP',
    'MCP',        // forces capitalised output even when spoken as a word
    'WebSocket',
    'OAuth',
    'kubectl',
    'gRPC',
    'Zod',
    'Prisma',
  ],
}));

// Vocabularies have a compile phase — 1-5 minutes to reach READY state
// Never reference the vocabulary in a job until it's READY
await waitForVocabularyReady('mcp-tools-vocab');

async function waitForVocabularyReady(name: string, maxAttempts = 30): Promise<void> {
  for (let i = 0; i < maxAttempts; i++) {
    await new Promise(r => setTimeout(r, 10_000)); // 10-second poll
    const { VocabularyState: state } = await transcribe.send(
      new GetVocabularyCommand({ VocabularyName: name })
    );
    if (state === VocabularyState.READY) return;
    if (state === VocabularyState.FAILED) {
      throw new Error(`Vocabulary ${name} failed to compile`);
    }
  }
  throw new Error(`Vocabulary ${name} did not reach READY within ${maxAttempts * 10}s`);
}

TSV file format with pronunciation hints

For words with unusual pronunciation, upload a TSV file to S3 with four columns. The Phrase column is what appears in the final transcript; IPA and SoundsLike are alternative ways to describe pronunciation (provide one, not both); DisplayAs overrides the output casing and format independently of pronunciation:

# vocabulary.tsv — tab-separated, UTF-8, header row required
Phrase	IPA	SoundsLike	DisplayAs
AliveMCP		uh-lyve-em-see-pee	AliveMCP
Kubernetes	kju.bɚˈneɪ.tɪs		Kubernetes
kubectl		kube-cuttle	kubectl
gRPC		gee-are-pee-see	gRPC
levetiracetam	lɛˌvɛtɪˈræsɪtæm		levetiracetam
OAuth		oh-auth	OAuth
tsconfig		tee-es-config	tsconfig

Column rules: provide either IPA or SoundsLike for pronunciation, not both. Leave both empty to register a word with its standard pronunciation but control output casing via DisplayAs. DisplayAs can include digits (OAuth2), hyphens (k8s), and uppercase letters.

The destructive-update trap

UpdateVocabularyCommand replaces the entire vocabulary — it is not an append or merge operation. The vocabulary immediately re-enters PENDING state after the call, and any job that starts while it is PENDING will either fail or silently fall back to the base model without the vocabulary applied:

// WRONG: immediate update then immediate job submission — job starts before READY
await transcribe.send(new UpdateVocabularyCommand({
  VocabularyName: 'mcp-tools-vocab',
  LanguageCode: 'en-US',
  Phrases: [ /* new list */ ],
}));
await transcribe.send(new StartTranscriptionJobCommand({ /* ... */ Settings: { VocabularyName: 'mcp-tools-vocab' } }));
// ^^^ May start while vocabulary is still PENDING

// CORRECT: update → wait for READY → submit job
await transcribe.send(new UpdateVocabularyCommand({
  VocabularyName: 'mcp-tools-vocab',
  LanguageCode: 'en-US',
  // Must pass the FULL desired list — not just the additions
  Phrases: ['AliveMCP', 'MCP', 'WebSocket', 'OAuth', 'kubectl', 'gRPC', 'Zod', 'Prisma', 'Hono'],
}));
await waitForVocabularyReady('mcp-tools-vocab');  // re-wait after every update
// Now safe to start jobs referencing this vocabulary

Maintain the canonical phrase list in your own configuration store (a JSON file in S3, a database column) rather than attempting to read the current vocabulary from AWS and append to it. Keep a versioned copy of your TSV in S3 — Transcribe replaces the previous file in its managed store when you update.

Vocabulary filters

Unlike vocabularies, vocabulary filters are available immediately after creation — no compile phase. The three filter methods handle different use cases:

await transcribe.send(new CreateVocabularyFilterCommand({
  VocabularyFilterName: 'profanity-en-us',
  LanguageCode: 'en-US',
  Words: ['word1', 'word2'], // replace with actual words to filter
}));
// Immediately usable — no polling required
VocabularyFilterMethodEffect on matched word in transcriptUse case
mask Replaces with *** in the output text Visible profanity filter — users can see a word was filtered but not what it was
remove Omits the word entirely from the transcript Clean transcripts for downstream parsing; removing filler words (um, uh) for readability
tag Keeps the word in the transcript text but adds VocabularyFilterMatch: true on the item Compliance audit logging — retain full text in a secure store, flag matched words for review without altering the transcript text downstream systems see

Both a vocabulary and a vocabulary filter can be active on the same batch job simultaneously. In streaming, they are top-level parameters on StartStreamingTranscriptionCommand (without the Settings wrapper). The vocabulary improves recognition before the transcript is written; the filter is applied to the output. You cannot apply two vocabularies or two filters to a single job simultaneously — merge them into a single vocabulary or filter file if needed.

Pattern 4: Medical transcription — Specialty selection, CONVERSATION vs DICTATION type, PHI identification, and HIPAA compliance

When an AI agent processes clinical audio — physician dictation, patient-provider encounters, radiology reports — the base Transcribe model is not the right choice. It lacks the acoustic training on medical speech patterns and doesn't recognise drug names, anatomical terms, or specialty abbreviations reliably. Transcribe Medical uses a speech model fine-tuned on clinical audio and adds structured PHI (Protected Health Information) identification that annotates patient data points in the transcript items array.

Basic tool implementation

import {
  TranscribeClient,
  StartMedicalTranscriptionJobCommand,
  GetMedicalTranscriptionJobCommand,
  MedicalTranscriptionJobStatus,
  Specialty,
  Type,
} from '@aws-sdk/client-transcribe';

server.tool('transcribe_clinical_audio', {
  s3_uri: z.string().regex(/^s3:\/\//),
  specialty: z.enum(['PRIMARYCARE', 'CARDIOLOGY', 'NEUROLOGY', 'ONCOLOGY', 'RADIOLOGY', 'UROLOGY'])
    .default('PRIMARYCARE'),
  type: z.enum(['CONVERSATION', 'DICTATION']).default('CONVERSATION'),
  output_bucket: z.string(),
  identify_phi: z.boolean().default(false),
  max_speakers: z.number().int().min(2).max(10).optional(),
}, async ({ s3_uri, specialty, type, output_bucket, identify_phi, max_speakers }) => {
  const jobName = `medical-${Date.now()}`;

  await transcribe.send(new StartMedicalTranscriptionJobCommand({
    MedicalTranscriptionJobName: jobName,
    LanguageCode: 'en-US',   // only en-US is supported for Transcribe Medical
    Specialty: specialty as Specialty,
    Type: type as Type,
    Media: { MediaFileUri: s3_uri },
    OutputBucketName: output_bucket,
    OutputKey: `medical-transcripts/${jobName}.json`,
    ContentIdentificationType: identify_phi ? 'PHI' : undefined,
    MedicalTranscriptionSetting: (type === 'CONVERSATION' && max_speakers)
      ? { ShowSpeakerLabels: true, MaxSpeakerLabels: max_speakers }
      : undefined,
    // ShowSpeakerLabels is ignored (or errors) for DICTATION type
  }));

  return { content: [{ type: 'text', text: JSON.stringify({ job_name: jobName, status: 'QUEUED' }) }] };
});

Specialty selection

SpecialtyOptimized forExample terms improved over base model
PRIMARYCARE Family medicine, internal medicine notes and patient conversations HbA1c, hypertension, dyslipidemia, COPD exacerbation, prednisone taper
CARDIOLOGY Cardiology notes, echocardiogram reports, cath lab dictation STEMI, troponin, ejection fraction, aortic stenosis, clopidogrel, atorvastatin
NEUROLOGY Neurology consults, EEG/EMG reports, neurological exam dictation GCS score, Babinski reflex, MRI FLAIR, levetiracetam, Parkinsonism, aphasia
ONCOLOGY Oncology notes, chemotherapy regimens, staging discussions FOLFOX, bevacizumab, PD-L1, TNM staging, neutropenic fever, G-CSF
RADIOLOGY Radiology report dictation (CT, MRI, X-ray, ultrasound) Hounsfield units, lytic lesion, hepatomegaly, pneumothorax, stenosis grade
UROLOGY Urology consults, cystoscopy dictation, prostate notes PSA velocity, Gleason score, hydronephrosis, cystoscopy with fulguration

Always choose the specialty that most closely matches the audio content. Using PRIMARYCARE for a cardiology dictation will produce a usable transcript but may mishear specialty-specific drug names and procedure terms. If the audio spans multiple specialties (a multi-system consultation note), choose the dominant specialty and supplement with a medical custom vocabulary for the cross-specialty terms.

CONVERSATION vs DICTATION type

TypeUse whenSpeaker diarization
CONVERSATION Patient-provider encounters: office visits, telemedicine calls, nurse triage, follow-up calls where multiple speakers alternate Available via MedicalTranscriptionSetting.ShowSpeakerLabels: true
DICTATION Single-speaker clinical note: physician recording a discharge summary, radiology report, operative note, progress note Not supported — ShowSpeakerLabels is ignored or returns an error on DICTATION jobs

Using DICTATION type for two-party audio causes the model to concatenate both speakers' words into a single stream with no attribution — the words are mostly correct but the transcript is structurally unreadable because there is no way to determine who said what. Accuracy is also lower because the DICTATION model is optimized for fluent clinical monologue, not turn-taking conversation with varying acoustic conditions.

PHI identification: annotation, not automatic redaction

Setting ContentIdentificationType: 'PHI' causes Transcribe Medical to annotate PHI-bearing words in the items array. The word still appears in results.transcripts[0].transcript unmodified — PHI identification is an annotation, not a redaction. To build a de-identified version, iterate the items array and substitute placeholders:

// PHI entity types identified in results.items[N].entities[]
// { content: string, category: 'PHI', type: 'NAME' | 'PHONE' | 'ADDRESS' | 'AGE' | 'ID' | 'DATE' | 'DIAGNOSIS', confidence: string }

function buildRedactedTranscript(items: any[]): string {
  const parts: string[] = [];
  for (const item of items) {
    if (item.type === 'punctuation') {
      // Attach punctuation to the previous word without a space
      if (parts.length) parts[parts.length - 1] += item.alternatives[0].content;
      continue;
    }
    if (item.type !== 'pronunciation') continue;

    const content = item.alternatives?.[0]?.content ?? '';
    const hasPHI = item.entities?.some((e: any) => e.category === 'PHI');

    // Substitute with the PHI type name for context (not just '[PHI]')
    if (hasPHI) {
      const phiType = item.entities.find((e: any) => e.category === 'PHI')?.type ?? 'PHI';
      parts.push(`[${phiType}]`);
    } else {
      parts.push(content);
    }
  }
  return parts.join(' ');
}

// Store the unredacted transcript in a bucket with restricted IAM access
// Share only the redacted version with downstream systems

PHI identification is not exhaustive — it uses context and position to infer PHI, so atypically phrased PHI may be missed. Do not rely solely on automated PHI identification for de-identification workflows that have legal safe-harbour requirements; apply a secondary manual review or rule-based regex pass for high-risk output.

Medical custom vocabulary

Standard vocabularies created with CreateVocabularyCommand are incompatible with medical transcription jobs. Use the separate CreateMedicalVocabularyCommand, which references the same TSV format but compiles the vocabulary against the medical speech model:

import { CreateMedicalVocabularyCommand, GetMedicalVocabularyCommand } from '@aws-sdk/client-transcribe';

await transcribe.send(new CreateMedicalVocabularyCommand({
  VocabularyName: 'oncology-drug-vocab',
  LanguageCode: 'en-US',
  VocabularyFileUri: 's3://my-assets/medical-vocabularies/oncology-drugs.tsv',
}));

// Wait for READY state (same polling pattern as standard vocabularies)
// Medical vocabulary compile time: 1-5 minutes typical; large files 15-20 minutes

// Use in a medical job:
await transcribe.send(new StartMedicalTranscriptionJobCommand({
  MedicalTranscriptionJobName: `medical-${Date.now()}`,
  LanguageCode: 'en-US',
  Specialty: 'ONCOLOGY',
  Type: 'DICTATION',
  Media: { MediaFileUri: 's3://recordings/note.wav' },
  OutputBucketName: 'transcripts-bucket',
  MedicalTranscriptionSetting: {
    VocabularyName: 'oncology-drug-vocab',  // must be a medical vocabulary, not standard
  },
}));

Focus medical vocabularies on terms that are specific to your specialty and genuinely difficult for the base model to recognise — rarely spoken investigational drug names, eponymous syndromes, uncommon procedure names. The base Transcribe Medical model already handles common drug classes and standard anatomical terms well; adding those to a custom vocabulary has no measurable benefit and increases compile time.

HIPAA compliance checklist

#RequirementNotes
1AWS Business Associate Addendum (BAA) signedRequired before processing any PHI. Enable in AWS Artifact → Agreements. Transcribe Medical is HIPAA-eligible but only under a signed BAA.
2Encrypt audio in S3 at rest with SSE-KMSUse a customer-managed KMS key. Enable S3 Object Lock on output buckets for audit immutability.
3Encrypt transcript output in S3 at restApply the same KMS key to the output bucket. Grant Transcribe kms:GenerateDataKey + kms:Decrypt for both input and output bucket keys.
4Restrict S3 access to output transcriptsIAM policy limiting s3:GetObject on transcript prefix to only authorised roles. Avoid granting s3:ListBucket broadly on transcript buckets.
5Enable S3 access logging and CloudTrail for all API callsLog all transcribe:StartMedicalTranscriptionJob and s3:GetObject calls. Set CloudTrail log retention ≥ 6 years for HIPAA.
6Define a data retention and deletion policyTranscribe does not retain audio or transcripts — your S3 bucket is the system of record. Apply S3 lifecycle rules to transition to Glacier and expire per your retention schedule.
7Do not log PHI in application logsNever log transcript text, job names derived from patient identifiers, or S3 keys containing MRNs. Log job IDs only. PHI in log files creates a secondary PHI disclosure risk.
8VPC endpoint for Transcribe in productionUse an interface VPC endpoint (com.amazonaws.us-east-1.transcribe) so PHI audio never traverses the public internet. Same for S3 — use an S3 gateway endpoint or S3 interface endpoint.

Medical streaming transcription

For real-time clinical audio — telemedicine sessions, ambient voice capture in a clinical encounter — medical streaming uses StartMedicalStreamingTranscriptionCommand from the same @aws-sdk/client-transcribe-streaming package as standard streaming, but returns MedicalTranscriptResultStream instead of TranscriptResultStream. PHI annotations are available in real time:

import { TranscribeStreamingClient, StartMedicalStreamingTranscriptionCommand } from '@aws-sdk/client-transcribe-streaming';

const command = new StartMedicalStreamingTranscriptionCommand({
  LanguageCode: 'en-US',
  MediaEncoding: 'pcm',
  MediaSampleRateHertz: 16000,
  Specialty: 'PRIMARYCARE',
  Type: 'CONVERSATION',
  AudioStream: audioStream(),
  ShowSpeakerLabel: true,
  ContentIdentificationType: 'PHI',  // PHI annotations in real time
});

const { MedicalTranscriptResultStream } = await streamingClient.send(command);

for await (const event of MedicalTranscriptResultStream as AsyncIterable<any>) {
  if (!event.TranscriptEvent) continue;
  for (const result of event.TranscriptEvent.Transcript.Results ?? []) {
    if (result.IsPartial) continue;
    const transcript = result.Alternatives?.[0]?.Transcript ?? '';
    const items = result.Alternatives?.[0]?.Items ?? [];
    const phiItems = items.filter((i: any) => i.entities?.some((e: any) => e.category === 'PHI'));
    console.log('Final:', transcript, '| PHI items:', phiItems.length);
  }
}

Medical streaming is subject to the same 5-concurrent-session default limit per region as standard streaming. The partial/final result semantics and stabilization settings work identically to standard streaming — the ResultId-keyed accumulator pattern from Pattern 2 applies unchanged.