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
- Batch jobs — call
StartTranscriptionJobCommandwith a unique job name, the S3 URI of your audio, eitherLanguageCodeorIdentifyLanguage: truewithLanguageOptions, and alwaysOutputBucketName; pollGetTranscriptionJobCommandevery 15–30 seconds (not in a blocking loop inside your tool handler — submit and return a job ID, poll in a separate step); whenCOMPLETED, fetch the transcript JSON from S3 and readresults.transcripts[0].transcriptfor full text andresults.items[]for word-level timestamps: OmittingOutputBucketNameforces an AWS-managed bucket with an expiring pre-signed URL — unreliable for async workflows. Speaker diarization:Settings.ShowSpeakerLabels: true+MaxSpeakerLabels: N(2–10, set to expected speaker count — a value higher than actual speaker count splits one speaker across multiple labels). Language identification: always passLanguageOptionswhen usingIdentifyLanguage: trueto restrict detection to plausible candidates — omitting it tests all supported languages, increasing cost and duration. Transcript items:type === 'pronunciation'entries havestart_time,end_time,alternatives[0].confidence, andspeaker_label(when diarization is on);type === 'punctuation'entries have no timestamps. PII redaction:ContentRedaction.RedactionType: 'PII'+RedactionOutput: 'redacted_and_unredacted'produces two output files — the redacted version replaces PII with[PII]placeholders, the unredacted version retains full text; always specifyPiiEntityTypes[]explicitly rather than relying on the default (which catches all PII types and may change as AWS adds new categories). IAM:transcribe:StartTranscriptionJob+transcribe:GetTranscriptionJob+s3:GetObjecton input bucket +s3:PutObjecton output bucket; if either bucket uses SSE-KMS, also grantkms:GenerateDataKey+kms:Decryptfor each key. - Streaming transcription — install
@aws-sdk/client-transcribe-streaming(not the batch SDK); create aTranscribeStreamingClientper session; callStartStreamingTranscriptionCommandwith a fixedLanguageCode(no auto-detection in streaming),MediaEncoding: 'pcm',MediaSampleRateHertzmatching your actual audio sample rate exactly, andAudioStreamas an async generator yielding{ AudioEvent: { AudioChunk: Uint8Array } }; iterateTranscriptResultStreamkeyed onResultId— replace partial results on the same ID, emit only whenIsPartial: false: Sample rate mismatch (MediaSampleRateHertzdoes not match actual PCM rate) produces garbled output with no error — not aBadRequestException. Chunk size sweet spot: 50–200 ms (800–3200 bytes at 16 kHz 16-bit); sub-10 ms chunks add HTTP/2 frame overhead; very large chunks add latency.PartialResultsStabilization: 'high'for AI agent pipelines — only marks words asStable: truewhen confident, reducing spurious mid-word changes that confuse downstream intent classifiers.PartialResultsStabilization: 'low'for live caption UIs where freshness matters. Error taxonomy:BadRequestExceptionin the event stream = wrong sample rate, unsupported language, or invalid parameters (not a thrown exception — appears as an event property);LimitExceededException= 5 concurrent streaming sessions per region limit hit — request increase via Service Quotas before production. HTTP/2 stream closes automatically on generator exhaustion or for-await break — no explicit teardown needed. - Custom vocabulary and filters — create a vocabulary with
CreateVocabularyCommandusingPhrases: string[]for simple word lists orVocabularyFileUripointing to an S3 TSV with four columns (Phrase, IPA, SoundsLike, DisplayAs); pollGetVocabularyCommanduntilVocabularyState === 'READY'before submitting any job that references it; create a vocabulary filter withCreateVocabularyFilterCommand(immediately available, no compile phase); choose filter method:mask(replaces with***),remove(omits word), ortag(addsVocabularyFilterMatch: trueon item without altering the transcript text): TSV column rules: provide eitherIPAorSoundsLikefor pronunciation hints (not both);DisplayAscontrols output casing and format independently of pronunciation.UpdateVocabularyCommandis a destructive full replacement — always pass the complete desired phrase list, not just the additions; the vocabulary re-entersPENDINGafter any update — gate subsequent job submissions on theREADYstate. Standard vocabularies are incompatible with Transcribe Medical jobs — useCreateMedicalVocabularyCommandfor medical. Naming:^[0-9a-zA-Z._-]+$— prefix with tenant ID in multi-tenant MCP servers to avoid name collisions across tenants (vocabulary names are global per account per region). Both a vocabulary and a filter can be active on the same job simultaneously viaSettings.VocabularyName+Settings.VocabularyFilterName+Settings.VocabularyFilterMethod; in streaming, these are top-level parameters onStartStreamingTranscriptionCommand(noSettingswrapper). - Medical transcription — call
StartMedicalTranscriptionJobCommandwithSpecialty(PRIMARYCARE/CARDIOLOGY/NEUROLOGY/ONCOLOGY/RADIOLOGY/UROLOGY),Type(CONVERSATION for multi-speaker encounters, DICTATION for single-speaker clinical notes),LanguageCode: 'en-US'(the only supported language), and alwaysOutputBucketName; setContentIdentificationType: 'PHI'to annotate PHI entities inresults.items[].entities[]without redacting the transcript text; useCreateMedicalVocabularyCommand(notCreateVocabularyCommand) for domain-specific drug names and procedures; useStartMedicalStreamingTranscriptionCommandfor real-time clinical audio:DICTATIONtype ignoresShowSpeakerLabels(returns an error or silently ignores it); never useDICTATIONfor two-person audio. PHI identification is annotation — the word still appears in the transcript text unmodified; to build a de-identified version, iterateitems[]and substitute[PHI]for items withentities[].category === 'PHI'; do not rely solely on automated PHI identification for legal safe-harbour de-identification. HIPAA prerequisite: execute the AWS BAA in AWS Artifact before processing any real patient audio — Transcribe Medical is HIPAA-eligible but only under a signed BAA. VPC endpoint: use an interface VPC endpoint (com.amazonaws.us-east-1.transcribe) in production so PHI audio never traverses the public internet. Never log transcript text, job names derived from patient identifiers, or S3 keys containing MRNs — log job IDs only.
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.
| MaxSpeakerLabels | Recommended when | Risk 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
| PartialResultsStability | Behaviour | Best 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
| Parameter | Values | Notes |
|---|---|---|
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
| VocabularyFilterMethod | Effect on matched word in transcript | Use 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
| Specialty | Optimized for | Example 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
| Type | Use when | Speaker 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
| # | Requirement | Notes |
|---|---|---|
| 1 | AWS Business Associate Addendum (BAA) signed | Required before processing any PHI. Enable in AWS Artifact → Agreements. Transcribe Medical is HIPAA-eligible but only under a signed BAA. |
| 2 | Encrypt audio in S3 at rest with SSE-KMS | Use a customer-managed KMS key. Enable S3 Object Lock on output buckets for audit immutability. |
| 3 | Encrypt transcript output in S3 at rest | Apply the same KMS key to the output bucket. Grant Transcribe kms:GenerateDataKey + kms:Decrypt for both input and output bucket keys. |
| 4 | Restrict S3 access to output transcripts | IAM policy limiting s3:GetObject on transcript prefix to only authorised roles. Avoid granting s3:ListBucket broadly on transcript buckets. |
| 5 | Enable S3 access logging and CloudTrail for all API calls | Log all transcribe:StartMedicalTranscriptionJob and s3:GetObject calls. Set CloudTrail log retention ≥ 6 years for HIPAA. |
| 6 | Define a data retention and deletion policy | Transcribe 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. |
| 7 | Do not log PHI in application logs | Never 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. |
| 8 | VPC endpoint for Transcribe in production | Use 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.