Guide · Amazon Lex v2 · Slots
MCP Server Lex v2 Slot Extraction — slot types, multi-turn elicitation, null handling
Slots are how Amazon Lex v2 turns free-form user utterances into structured data your MCP tool can act on. When a user says "book a flight to Paris on Friday", Lex maps that to an intent (BookFlight) and extracts two slot values (Destination: Paris, TravelDate: 2026-11-07). Your MCP tool reads those values from interpretations[0].intent.slots and uses them directly. The tricky parts are the three-level value structure (originalValue vs interpretedValue vs resolvedValues), null slot handling (a missing slot entry vs a slot entry with a null value are semantically different), and implementing a multi-turn elicitation loop so the bot collects all required slots before your tool executes. Getting any of these wrong produces tools that act on partially filled data or crash on a null-dereference when a required slot was not in the user's first utterance.
TL;DR
Slots live at response.interpretations[0].intent.slots as a Record<string, Slot | null>. Each filled slot has slot.value.interpretedValue (Lex's canonical resolution — prefer this over originalValue). A null entry means the slot was not filled in this turn. Check sessionState.dialogAction.type === "ElicitSlot" to know if Lex is asking for more slots before forwarding to fulfillment. Use built-in slot types (AMAZON.Date, AMAZON.Number, AMAZON.EmailAddress) to get canonical forms free — no parsing required. For multi-value slots (shape "List"), iterate slot.values[] instead of reading slot.value.
Slot data structure
The Slot object from the Lex v2 SDK has three important fields: value (for scalar slots), shape (Scalar or List), and values (for List-shape slots). The value sub-object itself has three fields that are frequently confused.
import type { Slot } from '@aws-sdk/client-lex-runtime-v2';
// SDK type (simplified for clarity):
// interface Slot {
// value?: {
// originalValue: string; // what the user literally said/typed
// interpretedValue: string; // what Lex resolved (canonical form)
// resolvedValues: string[]; // all candidate resolutions, usually 1 element
// };
// shape: 'Scalar' | 'List';
// values?: Slot[]; // for shape === 'List' only
// }
function getSlotValue(slot: Slot | null | undefined): string | null {
if (!slot || !slot.value) return null;
// interpretedValue is the canonical resolution — always prefer it over originalValue
// Example: user says "tomorrow", interpretedValue = "2026-11-06" (AMAZON.Date)
// user says "five", interpretedValue = "5" (AMAZON.Number)
// user says "NYC", interpretedValue = "New York City" (AMAZON.City)
return slot.value.interpretedValue ?? slot.value.originalValue;
}
// Usage:
const slots = response.interpretations?.[0]?.intent?.slots ?? {};
const destination = getSlotValue(slots['Destination']); // "Paris" or null
const travelDate = getSlotValue(slots['TravelDate']); // "2026-11-07" or null
const passengerCount = getSlotValue(slots['PassengerCount']); // "2" or null
originalValue is the raw text the user typed or spoke (useful for display/logging). interpretedValue is Lex's normalized canonical form — use this in your business logic. resolvedValues is an array of all plausible resolutions sorted by confidence; it usually has one element, but custom slot types with multiple synonyms can return more than one.
Built-in slot types reference
Built-in slot types (AMAZON.*) perform NLU normalization automatically — you receive a canonical interpretedValue without writing any parsing logic. The table below covers the types most useful in MCP server tools.
| 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 date: 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 duration: 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" → "how do I reset my password" |
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 — the interpretedValue is always an absolute YYYY-MM-DD string. Never try to parse relative dates yourself; use AMAZON.Date and read interpretedValue.
Multi-turn elicitation loop
When an intent requires multiple slots (e.g. a flight booking needs Destination, Origin, TravelDate, and PassengerCount), the bot may elicit slots across multiple turns. Your MCP tool needs to loop — sending each user message to Lex, rendering the bot's prompt, and waiting for the next input — until sessionState.dialogAction.type becomes "Close" or "ReadyForFulfillment".
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' });
interface ConversationState {
sessionId: string;
collectedSlots: Record<string, string>;
done: boolean;
}
export async function lexTurn(
userInput: string,
state: ConversationState,
): Promise<{ botReply: string; state: ConversationState }> {
const response = await lex.send(new RecognizeTextCommand({
botId: process.env.LEX_BOT_ID!,
botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
localeId: 'en_US',
sessionId: state.sessionId,
text: userInput,
}));
const dialogType = response.sessionState?.dialogAction?.type;
const botReply = response.messages?.[0]?.content ?? '';
// Accumulate slot values as they come in — slots may fill incrementally across turns
const rawSlots = response.interpretations?.[0]?.intent?.slots ?? {};
const updatedSlots = { ...state.collectedSlots };
for (const [name, slot] of Object.entries(rawSlots)) {
if (slot?.value?.interpretedValue) {
updatedSlots[name] = slot.value.interpretedValue;
}
}
const done = dialogType === 'Close' || dialogType === 'ReadyForFulfillment';
return {
botReply,
state: {
sessionId: state.sessionId,
collectedSlots: updatedSlots,
done,
},
};
}
// Caller creates state once and threads it through the loop:
// const state: ConversationState = { sessionId: randomUUID(), collectedSlots: {}, done: false };
// while (!state.done) {
// const userInput = await getUserInput(); // e.g. from MCP tool stream
// const result = await lexTurn(userInput, state);
// state = result.state;
// sendBotReply(result.botReply);
// }
// const { Destination, TravelDate } = state.collectedSlots;
Accumulate slots across turns rather than reading them only on the final turn. Lex may partially fill slots early (e.g. "I want to fly to Paris" fills Destination on turn 1, then asks for TravelDate on turn 2). If you only read slots on the "Close" turn, you may find them missing from interpretations[0].intent.slots if the Lambda fulfillment hook cleared them. Accumulate server-side from each intermediate response.
Null slot handling and required vs optional slots
The slots map uses a nullable value type: Record<string, Slot | null>. A null entry for a slot name means the slot was defined in the intent but was not filled in this turn. An absent key means the slot does not exist in the intent at all. These are different and your code must handle both.
function extractRequiredSlots(
slots: Record<string, { value?: { interpretedValue: string } } | null>,
required: string[],
): { values: Record<string, string>; missing: string[] } {
const values: Record<string, string> = {};
const missing: string[] = [];
for (const name of required) {
const slot = slots[name]; // undefined = slot not in intent
const val = slot?.value?.interpretedValue; // null/undefined = slot not yet filled
if (val) {
values[name] = val;
} else {
missing.push(name);
}
}
return { values, missing };
}
// In a fulfillment Lambda or tool handler:
const { values, missing } = extractRequiredSlots(slots, ['Destination', 'TravelDate', 'PassengerCount']);
if (missing.length > 0) {
// Shouldn't reach fulfillment with missing required slots — indicates a bot config error
throw new Error(`Fulfillment reached with unfilled required slots: ${missing.join(', ')}`);
}
// Safe to use values.Destination, values.TravelDate, values.PassengerCount
Lex's dialog management normally prevents fulfillment from running until all required slots are filled. If you reach a fulfillment step with missing required slots, it indicates either a bot misconfiguration (a slot that should be required is marked optional) or a Lambda dialog code hook that returned ReadyForFulfillment prematurely. Add the guard above in fulfillment code to catch these misconfigurations early.
List-shape slots for multi-value input
When a slot uses shape: "List", the user can supply multiple values in one utterance ("add milk, eggs, and bread to my list"). Lex fills slot.values[] (an array of Slot objects, each with a value) instead of the top-level slot.value.
function getListSlotValues(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 => v !== undefined);
}
// Fallback: Scalar slot — return single value as array for uniform handling
if (slot.value?.interpretedValue) {
return [slot.value.interpretedValue];
}
return [];
}
// Example: user says "add milk, eggs, and bread"
// slots['Item'].shape === 'List'
// slots['Item'].values === [
// { value: { interpretedValue: 'milk', originalValue: 'milk', resolvedValues: ['milk'] } },
// { value: { interpretedValue: 'eggs', originalValue: 'eggs', resolvedValues: ['eggs'] } },
// { value: { interpretedValue: 'bread', originalValue: 'bread', resolvedValues: ['bread'] } },
// ]
const items = getListSlotValues(slots['Item']); // ['milk', 'eggs', 'bread']
List-shape slots require the slot type to be configured with multipleValues: true in the Lex console (or multipleValuesSetting.allowMultipleValues: true in the SDK). Without this, Lex resolves only the last-mentioned value even when the user lists multiple items. Check your bot configuration if list extraction is returning only one value.