Guide · Amazon Lex v2 · Lambda Hooks

MCP Server Lex v2 Lambda Fulfillment — dialogCodeHook, fulfillmentCodeHook, dialogAction

Lambda code hooks let you inject business logic into every turn of a Lex v2 conversation — validating slot values, overriding dialog flow, passing context between turns, and executing the final action when all slots are filled. There are two distinct hook types: a dialog code hook that runs before each turn to validate slots and control the dialog state machine, and a fulfillment code hook that runs once when the intent is ready to execute. Both hooks receive the same event structure but return different dialogAction.type values, and the most common production failure is returning the wrong type or omitting required fields — which Lex silently treats as a BadGatewayException. Getting the response contract right requires understanding the six dialogAction.type values and when to use each one.

TL;DR

Dialog code hook: invoked before every turn — return Delegate to let Lex handle the next prompt, ElicitSlot (with slotToElicit) to override which slot to ask for, or Close to abort the conversation. Fulfillment code hook: invoked when all required slots are filled — execute your action and return Close with intent.state: "Fulfilled" on success or "Failed" on error. Pass context between turns via sessionAttributes (string key-value map, available in both hooks). Never throw from a code hook — always return a Close with state: "Failed" and a user-facing message on error; unhandled exceptions produce a DependencyFailedException in the caller.

Lambda event structure

Both dialog and fulfillment hooks receive the same Lambda event shape. The invocationSource field tells you which hook is running. Use it to branch your handler rather than writing two separate Lambda functions.

// TypeScript type for the Lex v2 Lambda event (not exported by the AWS SDK — define it yourself)
interface LexV2Event {
  messageVersion: '1.0';
  invocationSource: 'DialogCodeHook' | 'FulfillmentCodeHook';
  inputMode: 'Text' | 'Speech' | 'DTMF';
  responseContentType: string;
  sessionId: string;
  inputTranscript: string;  // what the user said/typed this turn
  bot: {
    id: string;
    name: string;
    aliasId: string;
    aliasName: 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: 'Failed' | 'Fulfilled' | 'FulfillmentInProgress' | 'InProgress' | 'ReadyForFulfillment' | 'Waiting';
      confirmationState: 'Confirmed' | 'Denied' | 'None';
    };
    nluConfidence?: { score: number };
  }>;
  sessionState: {
    dialogAction: { type: string; slotToElicit?: string };
    intent: {
      name: string;
      slots: Record<string, { value?: { interpretedValue: string } } | null>;
      state: string;
      confirmationState: string;
    };
    sessionAttributes: Record<string, string>;
    activeContexts?: unknown[];
  };
  requestAttributes?: Record<string, string>;
  proposedNextState?: unknown;
}

export const handler = async (event: LexV2Event) => {
  if (event.invocationSource === 'DialogCodeHook') {
    return handleDialogHook(event);
  }
  return handleFulfillmentHook(event);
};

Dialog code hook: per-turn validation and flow control

The dialog code hook fires on every turn before Lex generates its next prompt. Use it to validate slot values as they come in (e.g. reject past dates for a future-only booking), clear invalid slots, or override the dialog action to skip prompts the user already answered.

function handleDialogHook(event: LexV2Event) {
  const slots = event.sessionState.intent.slots;

  // Example: validate TravelDate is in the future
  const travelDate = slots['TravelDate']?.value?.interpretedValue;
  if (travelDate) {
    const date = new Date(travelDate);
    if (date < new Date()) {
      // Clear the invalid slot and re-elicit it with a custom prompt
      return {
        sessionState: {
          dialogAction: {
            type: 'ElicitSlot',
            slotToElicit: 'TravelDate',
          },
          intent: {
            name: event.sessionState.intent.name,
            slots: {
              ...slots,
              TravelDate: null, // clear the invalid value so Lex re-prompts
            },
            state: 'InProgress',
            confirmationState: 'None',
          },
          sessionAttributes: event.sessionState.sessionAttributes,
        },
        messages: [
          {
            contentType: 'PlainText',
            content: 'The travel date must be in the future. What date would you like to travel?',
          },
        ],
      };
    }
  }

  // All validations passed — delegate control back to Lex for standard dialog management
  return {
    sessionState: {
      dialogAction: { type: 'Delegate' },
      intent: {
        name: event.sessionState.intent.name,
        slots,
        state: 'InProgress',
        confirmationState: 'None',
      },
      sessionAttributes: event.sessionState.sessionAttributes,
    },
  };
}

Delegate is the most common dialog hook return: it tells Lex "I validated this turn and approve — continue with your normal dialog flow." Only return a non-Delegate action when you need to override Lex's default behavior. Returning Delegate does not skip slots — Lex still elicits them in the configured order.

Fulfillment code hook: executing the action

The fulfillment hook runs once when Lex determines all required slots are filled and the user has confirmed the intent (if confirmation is configured). This is where you execute the actual business logic — booking the flight, creating the ticket, sending the alert. Return Close with state: "Fulfilled" on success or "Failed" on error.

async function handleFulfillmentHook(event: LexV2Event) {
  const slots = event.sessionState.intent.slots;
  const attrs = event.sessionState.sessionAttributes;

  // Extract slot values safely
  const destination = slots['Destination']?.value?.interpretedValue;
  const travelDate  = slots['TravelDate']?.value?.interpretedValue;
  const passengerCount = Number(slots['PassengerCount']?.value?.interpretedValue ?? '1');

  if (!destination || !travelDate) {
    // Defensive: fulfillment should not run with missing required slots
    return closedResponse('Failed', `Missing required slot: ${!destination ? 'Destination' : 'TravelDate'}`);
  }

  try {
    // Execute business logic
    const bookingRef = await bookFlight({ destination, travelDate, passengerCount, userId: attrs['userId'] });

    // Store booking reference in sessionAttributes for downstream tools
    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 message = err instanceof Error ? err.message : 'An unexpected error occurred';
    return closedResponse('Failed', `Could not complete your booking: ${message}`);
  }
}

function closedResponse(state: 'Fulfilled' | 'Failed', message: string) {
  return {
    sessionState: {
      dialogAction: { type: 'Close' },
      intent: { name: '', slots: {}, state, confirmationState: 'None' },
      sessionAttributes: {},
    },
    messages: [{ contentType: 'PlainText', content: message }],
  };
}

Always catch exceptions in the fulfillment hook and return a Close with state: "Failed" and a user-visible message. If a Lambda code hook throws an unhandled exception, Lex returns a DependencyFailedException to the caller (your MCP tool) rather than the bot's response — this produces a confusing error that bypasses all your bot's error handling logic.

dialogAction.type reference

The return value of dialogAction.type controls what Lex does next. Using the wrong type for the situation is the most common code hook bug.

dialogAction.typeWhen to useRequired fields
DelegateValidation passed — let Lex manage the next dialog stepintent.name, intent.slots, intent.state
ElicitSlotAsk for a specific slot (can override Lex's default order)slotToElicit (slot name string); include messages[] for a custom prompt
ElicitIntentNo intent was matched — ask user to rephrase or state goalmessages[] with re-prompt text
ConfirmIntentAll slots filled — ask user "did you mean X?" before fulfillingintent with all slot values populated; messages[] with the confirmation question
CloseConversation turn is done (success or failure)intent.state ("Fulfilled" or "Failed"); messages[] with final message
NoneNo action — rarely used; Lex continues without direction—

When using ElicitSlot, always include a custom messages[] prompt. If you return ElicitSlot without messages, Lex falls back to the slot's configured "elicitation prompt" from the bot definition — which may be generic and not reflect the validation error that caused the re-elicitation.

Passing context between turns with sessionAttributes

sessionAttributes is a flat Record<string, string> that persists for the life of the session and is passed through every code hook invocation. Use it to carry context that is not expressible as slot values: authenticated user ID, tenant ID, feature flags, or accumulated state from prior turns.

// In a dialog hook — read and pass through attributes
function handleDialogHook(event: LexV2Event) {
  const attrs = event.sessionState.sessionAttributes;

  // Read a value set by the caller (passed via sessionState in RecognizeTextCommand)
  const userId = attrs['userId'] ?? 'anonymous';
  const tenantId = attrs['tenantId'] ?? 'default';

  // Add a computed value that the fulfillment hook will read
  const updatedAttrs = {
    ...attrs,
    validatedAt: new Date().toISOString(),
  };

  return {
    sessionState: {
      dialogAction: { type: 'Delegate' },
      intent: { ...event.sessionState.intent },
      sessionAttributes: updatedAttrs,  // merged attrs propagate to next turn
    },
  };
}

// Setting initial attributes from the caller (MCP server side):
const response = await lex.send(new RecognizeTextCommand({
  botId: process.env.LEX_BOT_ID!,
  botAliasId: process.env.LEX_ALIAS_ID ?? 'TSTALIASID',
  localeId: 'en_US',
  sessionId,
  text: userInput,
  sessionState: {
    sessionAttributes: {
      userId: authenticatedUser.id,
      tenantId: tenant.id,
      planTier: tenant.plan,
    },
  },
}));

All sessionAttributes values must be strings — serialize complex objects with JSON.stringify and parse with JSON.parse in the hook. The map is capped at 25KB total. For large payloads (file contents, long conversation history), store data externally and pass only a reference key in sessionAttributes.

Lambda permissions for Lex code hooks

Lex must have lambda:InvokeFunction permission on the code hook Lambda. This is usually added automatically when you configure the hook in the Lex console, but it must be added manually if you configure the bot via the SDK.

import { AddPermissionCommand, LambdaClient } from '@aws-sdk/client-lambda';

const lambda = new LambdaClient({ region: process.env.AWS_REGION ?? 'us-east-1' });

await lambda.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/${process.env.LEX_BOT_ID}/${process.env.LEX_ALIAS_ID}`,
}));

// The SourceArn condition is critical — it prevents any Lex bot alias in your account
// from invoking the hook, not just the intended bot.
// Without SourceArn, any Lex bot alias in your account can invoke this Lambda function.

If the Lambda resource policy is missing or incorrect, Lex returns a BadGatewayException to the caller — not a permissions error. BadGatewayException is Lex's generic "could not invoke the hook" error; check Lambda CloudWatch Logs and the Lex conversation logs for the underlying cause when you see it.