Amazon Lex v2 · 2026-11-05 · Lex v2 arc
Amazon Lex v2 for MCP Servers: RecognizeText, Slot Extraction, Lambda Fulfillment, and Audio Streaming
AliveMCP probes every registered MCP endpoint every 60 seconds — and the Lex v2 integration failures we see cluster around four mistakes that nearly every MCP server team makes in their first week: a conversational tool that generates a fresh sessionId on every turn instead of maintaining one per conversation thread, so Lex loses all dialog state and re-elicits every required slot from scratch on each call; a slot extraction handler that reads slot.value.originalValue and then tries to parse "next Friday" into a date with hand-rolled logic, when slot.value.interpretedValue for an AMAZON.Date slot already contains the resolved ISO 8601 string "2026-11-07"; a Lambda fulfillment hook that throws an unhandled exception on a business logic error, causing Lex to return a DependencyFailedException to the caller instead of a user-readable failure message; and an audio integration that reads the interpretations field from a RecognizeUtteranceCommand response and finds an object where it expected an array, because utterance responses encode those fields as base64 JSON strings — unlike text responses, which return parsed objects. Four Lex v2 APIs form the complete conversational AI toolkit for MCP servers. Text-based integration with RecognizeTextCommand is the entry point: you send the user's text, Lex returns ranked intent interpretations and response messages, and your tool reads the top intent to decide what to do next — the right design for any MCP server that orchestrates structured dialogs on behalf of an LLM. Slot extraction is how Lex maps free-form language to the typed data your tool needs: Lex handles all the NLU normalization and returns canonical values in interpretedValue, which means you get "2026-11-07" not "next Friday", "500" not "five hundred", and "+14155551212" not "four one five five five five one two one two" — but only if you read the right field and implement the multi-turn elicitation loop so slots fill incrementally. Lambda code hooks let you inject business logic into every turn: a dialog hook fires before each Lex prompt to validate slots and override the dialog flow, and a fulfillment hook fires once when all slots are collected to execute the actual action — the response contract requires returning the correct dialogAction.type and never throwing from a hook. Audio streaming extends text-based integration to voice input using RecognizeUtteranceCommand: you stream PCM-16 audio bytes, Lex handles both speech recognition and NLU in one call, and you decode the response fields (which are base64-encoded JSON in audio mode, unlike text mode) to get the same intent and slot data. This guide synthesizes all four Lex v2 patterns into a complete conversational AI integration stack for MCP server builders.
TL;DR
- Text integration — construct
LexRuntimeV2Clientonce; callRecognizeTextCommandwithbotId,botAliasId,localeId: 'en_US', a stable per-conversationsessionId, andtext; read the top intent frominterpretations[0].intent.name; checksessionState.dialogAction.typeto know if Lex wants more slots ("ElicitSlot") or is done ("Close"); rendermessages[0].contentback to the user: ThesessionIdmust be stable across all turns in a conversation — use a UUID generated at conversation start and stored in MCP tool context, never regenerate it per turn. Session TTL is 5 minutes of inactivity; Lex resets dialog state automatically after expiry. Add a jitter guard: generate a newsessionIdif the last turn was > 4 minutes ago. IAM needslex:RecognizeTextonarn:aws:lex:REGION:ACCOUNT:bot-alias/BOT_ID/ALIAS_ID— both IDs are 10-character alphanumeric strings from the Lex console; for the draft bot, use the literalTSTALIASID. For confidence threshold logic, checkinterpretations[0].nluConfidence.scoreand return a re-prompt message if it falls below 0.75 rather than forwarding a low-confidence interpretation. - Slot extraction — read filled slot values from
response.interpretations[0].intent.slotsas aRecord<string, Slot | null>; always useslot.value.interpretedValue(Lex's canonical form), neveroriginalValuefor business logic; a null slot entry means the slot was not yet filled this turn — accumulate slot values across turns rather than reading them only on the final turn; forAMAZON.Dateslots,interpretedValueis alwaysYYYY-MM-DDeven if the user said "next Friday": Built-inAMAZON.*slot types perform NLU normalization automatically:AMAZON.Numberconverts "five hundred" to"500";AMAZON.Dateresolves relative dates to absolute ISO 8601 strings;AMAZON.PhoneNumbernormalizes to E.164 format;AMAZON.Cityexpands abbreviations like "NYC" to "New York City". Loop untilsessionState.dialogAction.type === "Close"or"ReadyForFulfillment". For List-shape slots (slot configured withmultipleValues: true), readslot.values[](an array ofSlotobjects) rather thanslot.value. - Lambda fulfillment — in a dialog code hook, return
Delegatewhen validation passes andElicitSlot(with the slot cleared to null) when it fails; in a fulfillment code hook, returnClosewithintent.state: "Fulfilled"on success or"Failed"on error; never throw from a code hook — wrap all logic in try/catch and return aClosewith"Failed"on error; usesessionAttributesto pass context (userId, tenantId, computed values) between turns: Both hooks receive aLexV2EventwithinvocationSource: "DialogCodeHook" | "FulfillmentCodeHook"— write one Lambda that branches oninvocationSource. The dialog hook fires before every Lex prompt;Delegatetells Lex to continue its normal dialog management.ElicitSlotoverrides Lex's default slot elicitation order and requires amessages[]array with the custom prompt to show the user.sessionAttributesis a flatRecord<string, string>(all values must be strings) with a 25 KB cap; the caller injects initial attributes viasessionState.sessionAttributesinRecognizeTextCommand. The Lambda resource policy needslambda:InvokeFunctionwithPrincipal: 'lexv2.amazonaws.com'and aSourceArnscoped to the specific bot alias ARN to prevent confused-deputy attacks. - Audio streaming — use
RecognizeUtteranceCommandwithrequestContentType: 'audio/l16; rate=16000; channels=1'(PCM-16 mono at 16 kHz, the safest default); decode theinterpretationsandmessagesresponse fields from base64 JSON before reading them (unlikeRecognizeTextCommand, these fields are base64-encoded strings in utterance responses, not parsed objects); for audio output from Lex, setresponseContentType: 'audio/mpeg'and collect theaudioStream; MP3 input is not supported — convert withffmpeg -ar 16000 -ac 1 -f s16le: For most MCP server tools that orchestrate agent workflows,RecognizeTextCommandplus a separate Amazon Transcribe step gives better results thanRecognizeUtteranceCommand— it separates STT quality control from NLU and is significantly easier to test and debug. UseRecognizeUtteranceCommandonly when you have a ready audio source and need a single-hop call to Lex.StartConversationCommand(bidirectional HTTP/2 streaming) is designed for real-time voice kiosk and telephony applications — it is rarely appropriate for MCP server tools. SigV4a signing for audio commands is handled automatically by the AWS SDK v3; do not use custom HTTP handlers or manual signing middleware, which breaks streaming.
Pattern 1: Text-based integration — RecognizeTextCommand, session management, and confidence thresholds
When an MCP server tool needs to run a structured dialog — collecting several pieces of information from a user before executing an action — Lex v2 text integration is the right starting point. The model is simple: your tool sends one user utterance per turn, Lex returns the current intent state and a prompt for the next required slot, and your tool renders that prompt back to the user. The fundamental constraint is stateful session management: Lex stores dialog state server-side keyed on sessionId, and every turn in the same conversation must use the same sessionId. Failing this produces a bot that re-elicits every slot from scratch on each call.
Client setup and first call
The runtime SDK (@aws-sdk/client-lex-runtime-v2) is separate from the models SDK — you only need the runtime package for a tool that calls an existing bot. Construct the client once at module initialization; it is stateless and safe to share across concurrent tool calls.
import {
LexRuntimeV2Client,
RecognizeTextCommand,
type RecognizeTextCommandInput,
type RecognizeTextCommandOutput,
} from '@aws-sdk/client-lex-runtime-v2';
import { randomUUID } from 'crypto';
// Construct once — reuse across all calls
const lex = new LexRuntimeV2Client({
region: process.env.AWS_REGION ?? 'us-east-1',
});
const BOT_ID = process.env.LEX_BOT_ID!; // 10-char alphanumeric from Lex console
const BOT_ALIAS = process.env.LEX_ALIAS_ID!; // "TSTALIASID" for draft bot
const LOCALE_ID = 'en_US';
// Per-conversation session state — stored in MCP tool context, not per-call
interface LexSession {
sessionId: string;
lastTurnAt: number;
attributes: Record<string, string>;
}
const LEX_TTL_MS = 4 * 60 * 1000; // 4 min guard — Lex expires at 5 min
function getOrRefreshSession(
store: Map<string, LexSession>,
conversationId: string,
): LexSession {
const now = Date.now();
const existing = store.get(conversationId);
if (existing && now - existing.lastTurnAt < LEX_TTL_MS) return existing;
const session: LexSession = { sessionId: randomUUID(), lastTurnAt: now, attributes: {} };
store.set(conversationId, session);
return session;
}
export async function sendTurn(
userText: string,
conversationId: string,
store: Map<string, LexSession>,
extraAttributes?: Record<string, string>,
): Promise<RecognizeTextCommandOutput> {
const session = getOrRefreshSession(store, conversationId);
session.lastTurnAt = Date.now();
const input: RecognizeTextCommandInput = {
botId: BOT_ID,
botAliasId: BOT_ALIAS,
localeId: LOCALE_ID,
sessionId: session.sessionId,
text: userText,
sessionState: {
sessionAttributes: { ...session.attributes, ...extraAttributes },
},
};
return lex.send(new RecognizeTextCommand(input));
}
Parsing the response
The response has three main fields: interpretations (NLU results sorted by confidence), sessionState (dialog machine position), and messages (what the bot says next). Most tool implementations need a subset of these.
interface TurnResult {
topIntent: string;
confidence: number;
dialogAction: string;
slotToElicit?: string;
botMessages: string[];
done: boolean;
}
const CONFIDENCE_FLOOR = 0.75;
export function parseTurn(response: RecognizeTextCommandOutput): TurnResult {
const top = response.interpretations?.[0];
const intentName = top?.intent?.name ?? 'FallbackIntent';
const confidence = top?.nluConfidence?.score ?? 0;
const dialogAction = response.sessionState?.dialogAction?.type ?? 'None';
const slotToElicit = response.sessionState?.dialogAction?.slotToElicit;
const botMessages = (response.messages ?? [])
.filter(m => m.contentType === 'PlainText' || m.contentType === 'SSML')
.map(m => m.content ?? '');
// Low confidence or FallbackIntent — don't forward, re-prompt instead
const lowConfidence =
intentName === 'FallbackIntent' || confidence < CONFIDENCE_FLOOR;
return {
topIntent: intentName,
confidence,
dialogAction,
slotToElicit,
botMessages,
done: dialogAction === 'Close' && !lowConfidence,
};
}
// dialogAction.type quick reference:
// 'ElicitSlot' — Lex is asking for a specific slot (slotToElicit names which)
// 'ConfirmIntent' — all slots filled, Lex asking "did you mean X?"
// 'Close' — fulfillment complete or failed; conversation turn done
// 'ElicitIntent' — could not classify intent; asking user to rephrase
// 'Delegate' — Lambda hook delegated back to Lex standard flow
For MCP server tools that expose Lex to end users, a confidence floor of 0.70–0.80 is standard. For internal tooling where input arrives from an upstream LLM that has already normalized the utterance, you can drop the floor to 0.50. The key signal is dialogAction.type: when it is "Close", the conversation is finished — either fulfilled successfully by the fulfillment hook or explicitly failed. When it is "ElicitSlot", Lex is waiting for the next slot value and your tool should render messages[0].content as the next user-facing prompt.
IAM: minimal runtime permissions
The only permission required for text recognition is lex:RecognizeText scoped to the specific bot alias ARN. The ARN format is arn:aws:lex:REGION:ACCOUNT:bot-alias/BOT_ID/ALIAS_ID — both IDs are 10-character alphanumeric identifiers from the Lex console. The region in the ARN must match the client region and the bot's region exactly, or the call returns a ValidationException: BotId not found.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": ["lex:RecognizeText"],
"Resource": [
"arn:aws:lex:us-east-1:123456789012:bot-alias/ABCDE12345/FGHIJ67890"
]
}]
}
For the draft bot during development, the alias ID is the literal string TSTALIASID. When running on Lambda or ECS, attach this policy to the execution role rather than using long-term access keys.
Pattern 2: Slot extraction — interpretedValue, built-in types, multi-turn accumulation, and List-shape slots
Slot extraction is the bridge between Lex's NLU and your tool's typed data model. When a user says "book me two tickets to Paris for next Friday", Lex resolves that into slot values your tool can act on directly — but only if you read the right field and handle multi-turn accumulation correctly. The most common mistake is reading originalValue (what the user literally said) and then writing custom parsing logic, when interpretedValue already contains the canonical form Lex resolved: "2026-11-07", "2", and "Paris".
Slot data structure and the interpretedValue rule
import type { Slot } from '@aws-sdk/client-lex-runtime-v2';
// SDK Slot type (simplified):
// interface Slot {
// value?: {
// originalValue: string; // what the user said/typed — for display only
// interpretedValue: string; // Lex's canonical resolution — USE THIS
// resolvedValues: string[]; // all candidate resolutions, usually 1 element
// };
// shape: 'Scalar' | 'List';
// values?: Slot[]; // for shape === 'List' only
// }
function getSlot(slot: Slot | null | undefined): string | null {
if (!slot?.value) return null;
return slot.value.interpretedValue ?? slot.value.originalValue;
}
// Usage:
const slots = response.interpretations?.[0]?.intent?.slots ?? {};
const destination = getSlot(slots['Destination']); // "Paris" or null
const travelDate = getSlot(slots['TravelDate']); // "2026-11-07" or null
const passengerCount = getSlot(slots['PassengerCount']); // "2" or null
Built-in slot types: free normalization
Built-in AMAZON.* slot types normalize user input to canonical forms automatically — no parsing required in your tool. The table below covers the most useful types for MCP server integrations.
| Slot type | interpretedValue format | Example input → output |
|---|---|---|
| AMAZON.Number | Numeric string (integer or decimal) | "five hundred" → "500"; "three point five" → "3.5" |
| AMAZON.Date | ISO 8601: YYYY-MM-DD | "next Friday" → "2026-11-07"; "November 15th" → "2026-11-15" |
| AMAZON.Time | HH:mm 24-hour string | "three pm" → "15:00"; "9:30 in the morning" → "09:30" |
| AMAZON.Duration | ISO 8601: PTxHxM | "two hours thirty minutes" → "PT2H30M" |
| AMAZON.EmailAddress | Lowercase normalized email | "john at example dot com" → "john@example.com" |
| AMAZON.PhoneNumber | E.164 format: +1XXXXXXXXXX | "four one five five five five one two one two" → "+14155551212" |
| AMAZON.City | Full city name (English) | "NYC" → "New York City"; "LA" → "Los Angeles" |
| AMAZON.Country | ISO 3166-1 alpha-2 code | "United States" → "US"; "Germany" → "DE" |
| AMAZON.FirstName | Normalized given name | "mike" → "Mike" |
| AMAZON.SearchQuery | Raw text, no normalization | "how do I reset my password" → unchanged |
For AMAZON.Date, if the user says a relative date ("tomorrow", "in three days"), Lex resolves it against the current date at the time of the API call — interpretedValue is always an absolute YYYY-MM-DD string. Never parse relative dates yourself when you can use AMAZON.Date and read interpretedValue.
Multi-turn elicitation loop with slot accumulation
When an intent requires multiple slots, the user may not provide them all in the first utterance. Lex elicits missing slots across turns. Your tool needs to loop — sending each user message, rendering the bot's prompt, waiting for input — until dialogAction.type becomes "Close" or "ReadyForFulfillment". Critically, accumulate slot values across turns rather than reading them only on the final turn: Lex may partially fill slots early, and the fulfillment Lambda may clear them from the response after executing.
interface ConvState {
sessionId: string;
collectedSlots: Record<string, string>;
done: boolean;
}
export async function lexTurn(
userInput: string,
state: ConvState,
): Promise<{ botReply: string; state: ConvState }> {
const response = await lex.send(new RecognizeTextCommand({
botId: BOT_ID,
botAliasId: BOT_ALIAS,
localeId: LOCALE_ID,
sessionId: state.sessionId,
text: userInput,
}));
const dialogType = response.sessionState?.dialogAction?.type;
const botReply = response.messages?.[0]?.content ?? '';
// Merge newly filled slots into accumulated state each turn
const rawSlots = response.interpretations?.[0]?.intent?.slots ?? {};
const updated = { ...state.collectedSlots };
for (const [name, slot] of Object.entries(rawSlots)) {
const val = slot?.value?.interpretedValue;
if (val) updated[name] = val;
}
return {
botReply,
state: {
sessionId: state.sessionId,
collectedSlots: updated,
done: dialogType === 'Close' || dialogType === 'ReadyForFulfillment',
},
};
}
// Caller:
// let state: ConvState = { sessionId: randomUUID(), collectedSlots: {}, done: false };
// while (!state.done) {
// const userInput = await getUserInput();
// ({ botReply, state } = await lexTurn(userInput, state));
// renderBotReply(botReply);
// }
// const { Destination, TravelDate, PassengerCount } = state.collectedSlots;
Null slot guards and List-shape slots
The slots map type is Record<string, Slot | null>. A null entry means the slot was defined in the intent but not yet filled. An absent key means the slot does not exist in the intent at all. Add a guard in fulfillment code to catch bot misconfiguration:
function requireSlots(
slots: Record<string, { value?: { interpretedValue: string } } | null>,
required: string[],
): Record<string, string> {
const values: Record<string, string> = {};
const missing: string[] = [];
for (const name of required) {
const val = slots[name]?.value?.interpretedValue;
if (val) values[name] = val;
else missing.push(name);
}
if (missing.length)
throw new Error(`Fulfillment reached with unfilled required slots: ${missing.join(', ')}`);
return values;
}
// For List-shape slots (slot configured with multipleValues: true in Lex console):
function getListSlot(slot: Slot | null | undefined): string[] {
if (!slot) return [];
if (slot.shape === 'List' && slot.values) {
return slot.values
.map(s => s.value?.interpretedValue ?? s.value?.originalValue)
.filter((v): v is string => Boolean(v));
}
return slot.value?.interpretedValue ? [slot.value.interpretedValue] : [];
}
// Example: user says "add milk, eggs, and bread"
// slots['Item'].shape === 'List'
// getListSlot(slots['Item']) === ['milk', 'eggs', 'bread']
List-shape slots require the slot type to be configured with multipleValues: true in the Lex console. Without this, Lex resolves only the last-mentioned value even when the user lists multiple items. If list extraction returns only one value when you expected several, check the bot configuration before debugging your code.
Pattern 3: Lambda code hooks — dialog validation, fulfillment execution, and sessionAttributes
Lambda code hooks are the mechanism for injecting business logic into the Lex dialog engine without having your MCP server manage the full conversation loop itself. There are two hooks: a dialog code hook that fires before every turn to validate slots and control the dialog state machine, and a fulfillment code hook that fires once when all required slots are filled to execute the action. The most common failure is not understanding the response contract — particularly the difference between Delegate (validation passed, let Lex continue) and Close (conversation done, return result to user).
Lambda event structure
Both hooks receive the same Lambda event shape. Use invocationSource to branch rather than writing two separate Lambda functions.
// TypeScript type for the Lex v2 Lambda event (define yourself — not exported by SDK)
interface LexV2Event {
messageVersion: '1.0';
invocationSource: 'DialogCodeHook' | 'FulfillmentCodeHook';
inputMode: 'Text' | 'Speech' | 'DTMF';
sessionId: string;
inputTranscript: string;
bot: { id: string; name: string; aliasId: string; localeId: string; version: string };
interpretations: Array<{
intent: {
name: string;
slots: Record<string, {
value?: { originalValue: string; interpretedValue: string; resolvedValues: string[] };
shape: 'Scalar' | 'List';
values?: Array<{ value?: { interpretedValue: string } }>;
} | null>;
state: string;
confirmationState: string;
};
nluConfidence?: { score: number };
}>;
sessionState: {
dialogAction: { type: string; slotToElicit?: string };
intent: { name: string; slots: Record<string, unknown | null>; state: string; confirmationState: string };
sessionAttributes: Record<string, string>;
};
requestAttributes?: Record<string, string>;
}
export const handler = async (event: LexV2Event) => {
if (event.invocationSource === 'DialogCodeHook') {
return handleDialog(event);
}
return handleFulfillment(event);
};
Dialog code hook: validation and flow control
The dialog hook fires before every turn. Return Delegate when slots are valid — this tells Lex to continue its normal dialog management. Return ElicitSlot (with the invalid slot cleared to null and a custom prompt message) when a slot value fails validation. Never return Close from a dialog hook unless you want to abort the entire conversation.
function handleDialog(event: LexV2Event) {
const slots = event.sessionState.intent.slots as Record<string,
{ value?: { interpretedValue: string } } | null>;
const attrs = event.sessionState.sessionAttributes;
// Validate TravelDate is in the future
const travelDate = slots['TravelDate']?.value?.interpretedValue;
if (travelDate && new Date(travelDate) < new Date()) {
return {
sessionState: {
dialogAction: { type: 'ElicitSlot', slotToElicit: 'TravelDate' },
intent: {
name: event.sessionState.intent.name,
slots: { ...slots, TravelDate: null }, // clear invalid value
state: 'InProgress',
confirmationState: 'None',
},
sessionAttributes: attrs,
},
messages: [{
contentType: 'PlainText',
content: 'The travel date must be in the future. What date would you like to travel?',
}],
};
}
// All validations passed — delegate to Lex standard flow
return {
sessionState: {
dialogAction: { type: 'Delegate' },
intent: {
name: event.sessionState.intent.name,
slots,
state: 'InProgress',
confirmationState: 'None',
},
sessionAttributes: attrs,
},
};
}
Fulfillment code hook: executing the action
The fulfillment hook runs once when Lex determines all required slots are filled. Execute the business logic here and return Close with intent.state: "Fulfilled" on success or "Failed" on error. The critical rule is: never throw. Any unhandled exception produces a DependencyFailedException in the caller — a confusing Lex error that bypasses all your bot's error handling logic and shows no user-facing message.
async function handleFulfillment(event: LexV2Event) {
const slots = event.sessionState.intent.slots as Record<string,
{ value?: { interpretedValue: string } } | null>;
const attrs = event.sessionState.sessionAttributes;
const destination = slots['Destination']?.value?.interpretedValue;
const travelDate = slots['TravelDate']?.value?.interpretedValue;
const passengerCount = Number(slots['PassengerCount']?.value?.interpretedValue ?? '1');
if (!destination || !travelDate) {
return closedResponse('Failed',
`Missing required slot: ${!destination ? 'Destination' : 'TravelDate'}`);
}
try {
const bookingRef = await bookFlight({
destination,
travelDate,
passengerCount,
userId: attrs['userId'],
});
return {
sessionState: {
dialogAction: { type: 'Close' },
intent: { name: event.sessionState.intent.name, slots, state: 'Fulfilled', confirmationState: 'Confirmed' },
sessionAttributes: { ...attrs, lastBookingRef: bookingRef },
},
messages: [{
contentType: 'PlainText',
content: `Your flight to ${destination} on ${travelDate} is booked! Reference: ${bookingRef}`,
}],
};
} catch (err) {
const msg = err instanceof Error ? err.message : 'An unexpected error occurred';
return closedResponse('Failed', `Could not complete your booking: ${msg}`);
}
}
function closedResponse(state: 'Fulfilled' | 'Failed', message: string) {
return {
sessionState: {
dialogAction: { type: 'Close' },
intent: { name: '', slots: {}, state, confirmationState: 'None' },
sessionAttributes: {},
},
messages: [{ contentType: 'PlainText', content: message }],
};
}
dialogAction.type reference
| dialogAction.type | When to use | Required fields |
|---|---|---|
| Delegate | Validation passed — let Lex manage next dialog step | intent.name, intent.slots, intent.state |
| ElicitSlot | Ask for a specific slot (overrides Lex's default order) | slotToElicit (slot name); messages[] with custom prompt |
| ElicitIntent | No intent matched — ask user to rephrase or state goal | messages[] with re-prompt text |
| ConfirmIntent | All slots filled — ask user "did you mean X?" before fulfilling | intent with all slot values populated; messages[] with confirmation question |
| Close | Conversation turn is done (success or failure) | intent.state ("Fulfilled" or "Failed"); messages[] with final message |
| None | No specific action — Lex continues without direction | — |
sessionAttributes and Lambda permissions
sessionAttributes is a flat Record<string, string> (all values must be strings, 25 KB total cap) that persists for the life of the session. The caller injects initial values via sessionState.sessionAttributes in RecognizeTextCommand; both hooks receive and can extend the map. Use it for authenticated user ID, tenant ID, feature flags, or computed values that need to travel between turns.
The Lambda resource policy must grant lambda:InvokeFunction with Principal: 'lexv2.amazonaws.com' and a SourceArn scoped to the specific bot alias ARN. Without SourceArn, any Lex bot alias in your account can invoke the hook function — a confused-deputy vulnerability. Add the permission via AddPermissionCommand from @aws-sdk/client-lambda when configuring the bot programmatically:
import { LambdaClient, AddPermissionCommand } from '@aws-sdk/client-lambda';
await new LambdaClient({ region: 'us-east-1' }).send(new AddPermissionCommand({
FunctionName: 'lex-fulfillment-hook',
StatementId: 'lex-invoke-permission',
Action: 'lambda:InvokeFunction',
Principal: 'lexv2.amazonaws.com',
SourceAccount: process.env.AWS_ACCOUNT_ID!,
SourceArn: `arn:aws:lex:us-east-1:${process.env.AWS_ACCOUNT_ID}:bot-alias/${BOT_ID}/${BOT_ALIAS}`,
}));
// SourceArn scopes the permission to the specific bot alias — prevents confused-deputy attack
When you configure the hook in the Lex console, the permission is added automatically. When configuring via the models SDK or infrastructure-as-code, add it explicitly. Missing or incorrect Lambda permissions produce a BadGatewayException in the runtime caller — not an IAM error message. BadGatewayException is Lex's generic "could not invoke hook" error; check Lambda CloudWatch Logs when you see it. For more patterns on Lambda async workflows with Lex hooks, see the Lambda destinations guide.
Pattern 4: Audio streaming — RecognizeUtteranceCommand, format constraints, base64 response decoding, and SigV4a
Audio streaming extends Lex integration to voice input: instead of calling RecognizeTextCommand with text, you call RecognizeUtteranceCommand with raw audio bytes, and Lex handles both speech recognition and NLU in a single API call. The two things that catch developers off-guard are the audio format constraints (PCM-16 at 8 or 16 kHz only — MP3 and stereo are not supported for input) and the response format difference (the interpretations and messages fields come back as base64-encoded JSON strings in audio mode, unlike the parsed objects returned by RecognizeTextCommand).
When to use audio vs text
| Approach | When to use | Tradeoffs |
|---|---|---|
| RecognizeTextCommand | MCP tool receives text from an LLM or user form input | Simplest; no audio format concerns; easy to test |
| RecognizeTextCommand + Transcribe STT | Audio input, but you want custom vocabulary or speaker diarization | Two API calls; more latency; full STT control |
| RecognizeUtteranceCommand | Audio input, single-hop low-latency required; Lex handles both STT and NLU | One API call; SigV4a complexity; less STT control; format constraints |
| StartConversationCommand | Real-time bidirectional voice (phone/kiosk) where audio response latency matters | HTTP/2 event streams; highest complexity; not suitable for most MCP tools |
For agentic MCP server tools that orchestrate conversation flows, RecognizeTextCommand plus a separate Amazon Transcribe streaming step gives more control: you can use custom vocabularies tuned to your domain, control partial results stabilization, and inspect the raw transcript before it reaches Lex. Reserve RecognizeUtteranceCommand for cases where the audio source arrives ready and you want a single-hop call.
Audio format requirements
| requestContentType | Format | Notes |
|---|---|---|
| audio/l16; rate=16000; channels=1 | PCM 16-bit, 16 kHz, mono | Safest default; lossless; ~1 MB/min; always accepted |
| audio/l16; rate=8000; channels=1 | PCM 16-bit, 8 kHz, mono | Telephone quality; lower accuracy on natural speech |
| audio/x-flac | FLAC lossless | Smaller than PCM; requires FLAC encoder |
| audio/x-cbr-opus-with-preamble; ... | Opus CBR | Smallest; requires specific CBR settings; preamble-size must match actual bytes |
MP3 is not supported for input. If your audio source produces MP3, convert to PCM-16 before sending: ffmpeg -i input.mp3 -ar 16000 -ac 1 -f s16le output.raw. Stereo audio is also not supported — convert to mono by mixing channels. This surprises developers coming from Amazon Transcribe, which does accept MP3.
Sending audio and decoding the response
import {
LexRuntimeV2Client,
RecognizeUtteranceCommand,
type RecognizeUtteranceCommandInput,
} from '@aws-sdk/client-lex-runtime-v2';
const lex = new LexRuntimeV2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
export async function recognizeAudio(
audioBuffer: Buffer,
sessionId: string,
): Promise<{ transcription: string; intentName: string; botReply: string }> {
const input: RecognizeUtteranceCommandInput = {
botId: process.env.LEX_BOT_ID!,
botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
localeId: 'en_US',
sessionId,
requestContentType: 'audio/l16; rate=16000; channels=1',
responseContentType: 'text/plain; charset=utf-8', // request text back (not audio)
inputStream: audioBuffer,
};
const response = await lex.send(new RecognizeUtteranceCommand(input));
// inputTranscript is a plain string — what Lex heard
const transcription = response.inputTranscript ?? '';
// IMPORTANT: interpretations and messages in utterance responses are base64-encoded JSON strings.
// This is different from RecognizeTextCommand, which returns parsed objects.
// Always decode before parsing.
const decodeField = (field: unknown): unknown[] => {
if (!field) return [];
return JSON.parse(Buffer.from(field as string, 'base64').toString('utf8'));
};
const interpretations = decodeField(response.interpretations) as
Array<{ intent?: { name: string } }>;
const messages = decodeField(response.messages) as
Array<{ content?: string }>;
return {
transcription,
intentName: interpretations[0]?.intent?.name ?? 'Unknown',
botReply: messages[0]?.content ?? '',
};
}
The base64 decoding step is the most common mistake when migrating from text mode to audio mode. RecognizeTextCommand returns interpretations as a parsed Interpretation[] array; RecognizeUtteranceCommand returns the same data encoded as a base64 JSON string. The TypeScript types in the SDK reflect this difference, but it is easy to miss at a glance.
Requesting audio output from Lex
If your MCP server needs to return audio (for a voice interface), set responseContentType to an audio MIME type. Lex synthesizes the bot's response text using Amazon Polly and streams the audio back in the response body.
const response = await lex.send(new RecognizeUtteranceCommand({
botId: process.env.LEX_BOT_ID!,
botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
localeId: 'en_US',
sessionId,
requestContentType: 'audio/l16; rate=16000; channels=1',
responseContentType: 'audio/mpeg', // Lex synthesizes bot reply via Polly, returns MP3
inputStream: audioBuffer,
}));
if (response.audioStream) {
const chunks: Buffer[] = [];
for await (const chunk of response.audioStream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
const audioResponse = Buffer.concat(chunks);
// Stream audioResponse to the voice client
}
// Supported responseContentType values for audio output:
// 'audio/mpeg' — MP3, compressed, most compatible with playback clients
// 'audio/ogg; codecs=opus' — Opus in OGG container, smaller than MP3
// 'audio/pcm' — PCM 16-bit 16 kHz raw bytes
The Polly voice and engine used for audio output are configured in the bot's locale settings in the Lex console — you cannot override them per API call. If you need fine-grained voice control, use the text response from Lex and then call Polly separately via SynthesizeSpeechCommand with your chosen VoiceId and engine.
SigV4a: what the SDK handles for you
Audio streaming commands (RecognizeUtteranceCommand and StartConversationCommand) require SigV4a — the asymmetric, multi-region variant of AWS request signing — instead of the standard SigV4 used by most AWS API calls. The AWS SDK for JavaScript v3 handles this automatically. You do not need to configure anything extra in the client.
// No special configuration needed for SigV4a
const lex = new LexRuntimeV2Client({
region: process.env.AWS_REGION ?? 'us-east-1',
// SDK detects that RecognizeUtteranceCommand requires streaming and applies SigV4a
});
// If you receive SignatureDoesNotMatch on audio commands:
// 1. Upgrade @aws-sdk/client-lex-runtime-v2 to v3.400.0+ (older versions had SigV4a issues)
// 2. Ensure AWS_REGION is set — SigV4a requires a valid region
// 3. Ensure credentials have lex:RecognizeUtterance — not just lex:RecognizeText
// 4. Do NOT use a custom httpHandler or manual signing middleware — it breaks streaming
Developers who proxy Lex audio calls through a backend encounter SigV4a the most. If your proxy server re-signs the request with standard SigV4, Lex will reject it. The request must be signed by the original calling principal. For MCP server tools that proxy voice from a client app, the preferred pattern is to generate pre-signed credentials on the server and have the client call Lex directly.
StartConversationCommand: when and when not to use it
StartConversationCommand enables true bidirectional streaming over HTTP/2: you send audio frames while simultaneously receiving transcription updates and audio responses. This is designed for real-time voice kiosks and telephony applications — not for typical MCP server tools. The event stream architecture requires handling incomplete audio frames, out-of-order events, and reconnection logic. Use it only when you are building a real-time voice interface where end-to-end audio latency is a hard requirement. The event sequence is: ConfigurationEvent (must be first, defines bot and audio format) → AudioInputEvent frames → DisconnectEvent on the input side; AudioResponseEvent, IntentResultEvent, and TranscriptEvent on the output side.
Putting it together: a complete Lex v2 MCP tool
The four patterns above compose into a working conversational MCP tool. The tool accepts user messages, maintains a session per conversation, accumulates slot values across turns, and delegates fulfillment to a Lambda code hook that executes the action and returns the result.
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { LexRuntimeV2Client, RecognizeTextCommand } from '@aws-sdk/client-lex-runtime-v2';
import { randomUUID } from 'crypto';
const lex = new LexRuntimeV2Client({ region: process.env.AWS_REGION ?? 'us-east-1' });
const server = new McpServer({ name: 'booking-bot', version: '1.0.0' });
// In-memory session store — replace with Redis for multi-instance deployments
const sessions = new Map<string, { sessionId: string; lastAt: number; slots: Record<string, string> }>();
server.tool('book_flight_turn', {
conversation_id: z.string().describe('Stable ID for this conversation thread'),
user_message: z.string().describe('The user message for this turn'),
user_id: z.string().optional().describe('Authenticated user ID to pass to fulfillment Lambda'),
}, async ({ conversation_id, user_message, user_id }) => {
const now = Date.now();
let session = sessions.get(conversation_id);
// Reset session if expired (Lex TTL is 5 min; guard at 4 min)
if (!session || now - session.lastAt > 4 * 60 * 1000) {
session = { sessionId: randomUUID(), lastAt: now, slots: {} };
sessions.set(conversation_id, session);
}
session.lastAt = now;
const response = await lex.send(new RecognizeTextCommand({
botId: process.env.LEX_BOT_ID!,
botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
localeId: 'en_US',
sessionId: session.sessionId,
text: user_message,
sessionState: {
sessionAttributes: user_id ? { userId: user_id } : {},
},
}));
// Accumulate slots
const rawSlots = response.interpretations?.[0]?.intent?.slots ?? {};
for (const [name, slot] of Object.entries(rawSlots)) {
const val = slot?.value?.interpretedValue;
if (val) session.slots[name] = val;
}
const dialogType = response.sessionState?.dialogAction?.type;
const botReply = response.messages?.[0]?.content ?? "I'm sorry, I didn't understand that.";
const done = dialogType === 'Close';
if (done) sessions.delete(conversation_id); // clean up completed conversations
return {
content: [{
type: 'text',
text: JSON.stringify({
bot_reply: botReply,
dialog_state: dialogType,
done,
collected_slots: done ? session.slots : undefined,
}),
}],
};
});
For production deployments, replace the in-memory sessions map with a Redis-backed store so the session state survives process restarts and is shared across multiple MCP server instances. Store the sessionId and lastAt fields in Redis with a TTL of 4 minutes to match the guard logic above.
Error handling reference
| Error | Cause | Fix |
|---|---|---|
| AccessDeniedException | IAM missing lex:RecognizeText on the bot alias ARN | Verify ARN format: arn:aws:lex:REGION:ACCOUNT:bot-alias/BOT_ID/ALIAS_ID; check region matches |
| ValidationException: BotId not found | botId or botAliasId does not exist in this region | Get IDs from Lex console → Bot details; ensure client region matches bot region |
| ValidationException: BotAlias not ready | Bot alias is in building or failed state | Wait for bot to reach "Built" state; check for intent/slot type errors in Lex console |
| ThrottlingException | Exceeded 5 TPS limit per bot alias per region | Add jittered exponential backoff; request quota increase via Service Quotas |
| DependencyFailedException | Lambda code hook threw unhandled exception or timed out | Check Lambda CloudWatch Logs; ensure hook returns required sessionState.dialogAction.type; never throw from hooks |
| BadGatewayException | Lex could not reach Lambda code hook (permissions or VPC) | Verify Lex has lambda:InvokeFunction with SourceArn on the hook function; check VPC config |
| SignatureDoesNotMatch (audio only) | SigV4a signing issue on RecognizeUtteranceCommand | Upgrade SDK to v3.400.0+; do not use custom httpHandler; ensure AWS_REGION is set |
| InternalServerException | Transient Lex service error | Retry with exponential backoff; check AWS Service Health Dashboard if persistent |