diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index db675b83..5acedad0 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -219,6 +219,11 @@ jobs:
       run: dotnet test ./test/LondonTravel.Skill.EndToEndTests --configuration Release --logger "GitHubActions;report-warnings=false"
       env:
         LAMBDA_FUNCTION_NAME: ${{ env.LAMBDA_FUNCTION }}-dev
+        LWA_CLIENT_ID: ${{ secrets.LWA_CLIENT_ID }}
+        LWA_CLIENT_SECRET: ${{ secrets.LWA_CLIENT_SECRET }}
+        LWA_REFRESH_TOKEN: ${{ secrets.LWA_REFRESH_TOKEN }}
+        SKILL_ID: ${{ secrets.SKILL_ID }}
+        SKILL_STAGE: development
 
   deploy-prod:
     name: production
@@ -327,3 +332,8 @@ jobs:
       run: dotnet test ./test/LondonTravel.Skill.EndToEndTests --configuration Release --logger "GitHubActions;report-warnings=false"
       env:
         LAMBDA_FUNCTION_NAME: ${{ env.LAMBDA_FUNCTION }}
+        LWA_CLIENT_ID: ${{ secrets.LWA_CLIENT_ID }}
+        LWA_CLIENT_SECRET: ${{ secrets.LWA_CLIENT_SECRET }}
+        LWA_REFRESH_TOKEN: ${{ secrets.LWA_REFRESH_TOKEN }}
+        SKILL_ID: ${{ secrets.SKILL_ID }}
+        SKILL_STAGE: live
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index f9db4fe9..bf239f54 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -387,7 +387,12 @@ jobs:
       env:
         GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
         LAMBDA_FUNCTION_NAME: ${{ needs.setup.outputs.function-name }}
+        LWA_CLIENT_ID: ${{ secrets.LWA_CLIENT_ID }}
+        LWA_CLIENT_SECRET: ${{ secrets.LWA_CLIENT_SECRET }}
+        LWA_REFRESH_TOKEN: ${{ secrets.LWA_REFRESH_TOKEN }}
         PULL_NUMBER: ${{ github.event.issue.number }}
+        SKILL_ID: ${{ secrets.SKILL_ID }}
+        SKILL_STAGE: development
 
     - name: Post comment
       uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1
diff --git a/static/interaction-model.json b/static/interaction-model.json
index 11f4bf88..87cbc4b8 100644
--- a/static/interaction-model.json
+++ b/static/interaction-model.json
@@ -940,6 +940,62 @@
           ]
         }
       ]
+    },
+    "_nameFreeInteraction": {
+      "ingressPoints": [
+        {
+          "type": "INTENT",
+          "sampleUtterances": [
+            {
+              "format": "RAW_TEXT",
+              "value": "Are there any problems with London Underground"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "Are there any engineering works on the tube today"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "Are there delays on the underground right now"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "Is there any disruption on the tube"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "Tube delays"
+            }
+          ],
+          "name": "DisruptionIntent"
+        },
+        {
+          "type": "INTENT",
+          "sampleUtterances": [
+            {
+              "format": "RAW_TEXT",
+              "value": "Are there any delays on the Central Line"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "Are there any engineering works on the Victoria Line this weekend"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "Is the Northern Line suspended"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "Is there any disruption on the District Line today"
+            },
+            {
+              "format": "RAW_TEXT",
+              "value": "What problems are there on the Piccadilly line"
+            }
+          ],
+          "name": "StatusIntent"
+        }
+      ]
     }
   }
 }
diff --git a/static/skill.json b/static/skill.json
new file mode 100644
index 00000000..61bac6fa
--- /dev/null
+++ b/static/skill.json
@@ -0,0 +1,58 @@
+{
+  "manifest": {
+    "publishingInformation": {
+      "locales": {
+        "en-GB": {
+          "summary": "Find out the current status of public transport in London.",
+          "examplePhrases": [
+            "Alexa, ask London Travel what\u0027s the status of the Northern line",
+            "Alexa, ask London Travel are there any line closures",
+            "Alexa, ask London Travel about my commute"
+          ],
+          "keywords": [
+            "london",
+            "travel",
+            "tube",
+            "tfl",
+            "dlr",
+            "underground",
+            "overground"
+          ],
+          "name": "London Travel",
+          "description": "This skill allows you to ask Alexa for information about disruption on public transport in London.\n\nFor example, you can ask about disruption affecting London Underground, London Overground, the Docklands Light Railway (DLR) and TfL Rail as a whole, or you can ask for information about a specific line.\n\nIf you create an account with London Travel and link it with the Alexa app, you can also ask about your commute for your favourite lines. You can create an account with London Travel using a login for existing accounts with other services, such as Amazon, Facebook and Twitter.\n\nYou can manage your London Travel account and set up your preferences at https://londontravel.martincostello.com/.",
+          "smallIconUri": "file://icon-108x108.png",
+          "largeIconUri": "file://icon-512x512.png"
+        }
+      },
+      "isAvailableWorldwide": false,
+      "testingInstructions": "To create an account for testing account linking, you must have an existing account with any of the following services:\n\nAmazon\nFacebook\nMicrosoft\nTwitter\n\nGoogle can be used to create an account in the website, but it cannot be used to link an account from the skill due to Google restrictions on sign-in from in-app browsers.\n\nTo test the \"commute\" intent, you must have created an account, selected at least one favourite tube line, and linked it to the Alexa app.",
+      "category": "PUBLIC_TRANSPORTATION",
+      "distributionCountries": [
+        "GB"
+      ]
+    },
+    "apis": {
+      "custom": {
+        "endpoint": {
+          "uri": ""
+        },
+        "interfaces": []
+      }
+    },
+    "manifestVersion": "1.0",
+    "permissions": [],
+    "privacyAndCompliance": {
+      "allowsPurchases": false,
+      "locales": {
+        "en-GB": {
+          "termsOfUseUrl": "https://londontravel.martincostello.com/terms-of-service/",
+          "privacyPolicyUrl": "https://londontravel.martincostello.com/privacy-policy/"
+        }
+      },
+      "containsAds": false,
+      "isExportCompliant": true,
+      "isChildDirected": false,
+      "usesPersonalInfo": true
+    }
+  }
+}
diff --git a/test/LondonTravel.Skill.EndToEndTests/CloudWatchLogsFixture.cs b/test/LondonTravel.Skill.EndToEndTests/CloudWatchLogsFixture.cs
index 046c6b3e..735dc874 100644
--- a/test/LondonTravel.Skill.EndToEndTests/CloudWatchLogsFixture.cs
+++ b/test/LondonTravel.Skill.EndToEndTests/CloudWatchLogsFixture.cs
@@ -2,7 +2,6 @@
 // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.
 
 using System.Net.Http.Json;
-using System.Reflection;
 using Amazon;
 using Amazon.CloudWatchLogs;
 using Xunit.Sdk;
@@ -24,9 +23,9 @@ public async Task DisposeAsync()
             return;
         }
 
-        var credentials = AwsConfiguration.GetCredentials();
-        string functionName = AwsConfiguration.FunctionName;
-        string regionName = AwsConfiguration.RegionName;
+        var credentials = TestConfiguration.GetCredentials();
+        string functionName = TestConfiguration.FunctionName;
+        string regionName = TestConfiguration.RegionName;
 
         if (functionName is not null &&
             regionName is not null &&
@@ -205,7 +204,7 @@ private static async Task TryPostLogsToPullRequestAsync(IEnumerable<LogEvent> ev
             {
                 Accept = { new("application/vnd.github+json") },
                 Authorization = new("Bearer", token),
-                UserAgent = { new("LondonTravel.Skill.EndToEndTests", typeof(CloudWatchLogsFixture).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion) },
+                UserAgent = { TestConfiguration.UserAgent },
             },
         };
 
diff --git a/test/LondonTravel.Skill.EndToEndTests/LambdaTests.cs b/test/LondonTravel.Skill.EndToEndTests/LambdaTests.cs
new file mode 100644
index 00000000..57242b33
--- /dev/null
+++ b/test/LondonTravel.Skill.EndToEndTests/LambdaTests.cs
@@ -0,0 +1,104 @@
+// Copyright (c) Martin Costello, 2017. All rights reserved.
+// Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.
+
+using System.Net;
+using System.Text.Json;
+using Amazon;
+using Amazon.Lambda;
+using Amazon.Lambda.Model;
+using LondonTravel.Skill.EndToEndTests;
+
+namespace MartinCostello.LondonTravel.Skill;
+
+[Collection(CloudWatchLogsFixtureCollection.Name)]
+public class LambdaTests(CloudWatchLogsFixture fixture, ITestOutputHelper outputHelper)
+{
+    public static IEnumerable<object[]> Payloads
+    {
+        get
+        {
+            return Directory.GetFiles("Payloads")
+                .Select(Path.GetFileNameWithoutExtension)
+                .Order()
+                .Select((p) => new object[] { p })
+                .ToArray();
+        }
+    }
+
+    [SkippableTheory]
+    [MemberData(nameof(Payloads))]
+    public async Task Can_Invoke_Intent_Can_Get_Json_Response(string payloadName)
+    {
+        var credentials = TestConfiguration.GetCredentials();
+
+        Skip.If(credentials is null, "No AWS credentials are configured.");
+
+        string functionName = TestConfiguration.FunctionName;
+        string regionName = TestConfiguration.RegionName;
+
+        Skip.If(string.IsNullOrEmpty(functionName), "No Lambda function name is configured.");
+        Skip.If(string.IsNullOrEmpty(regionName), "No AWS region name is configured.");
+
+        // Arrange
+        string payload = await File.ReadAllTextAsync(Path.Combine("Payloads", $"{payloadName}.json"));
+
+        var region = RegionEndpoint.GetBySystemName(regionName);
+
+        using var client = new AmazonLambdaClient(credentials, region);
+
+        var request = new InvokeRequest()
+        {
+            FunctionName = functionName,
+            InvocationType = InvocationType.RequestResponse,
+            LogType = LogType.None,
+            Payload = payload,
+        };
+
+        outputHelper.WriteLine($"FunctionName: {request.FunctionName}");
+        outputHelper.WriteLine($"Payload: {request.Payload}");
+
+        // Act
+        InvokeResponse invocation = await client.InvokeAsync(request);
+
+        // Assert
+        invocation.ShouldNotBeNull();
+        invocation.ResponseMetadata.ShouldNotBeNull();
+
+        fixture.Requests[invocation.ResponseMetadata.RequestId] = payloadName;
+
+        using var reader = new StreamReader(invocation.Payload);
+        string responsePayload = await reader.ReadToEndAsync();
+
+        outputHelper.WriteLine($"ExecutedVersion: {invocation.ExecutedVersion}");
+        outputHelper.WriteLine($"FunctionError: {invocation.FunctionError}");
+        outputHelper.WriteLine($"HttpStatusCode: {invocation.HttpStatusCode}");
+        outputHelper.WriteLine($"RequestId: {invocation.ResponseMetadata.RequestId}");
+        outputHelper.WriteLine($"StatusCode: {invocation.StatusCode}");
+        outputHelper.WriteLine($"Payload: {responsePayload}");
+
+        invocation.HttpStatusCode.ShouldBe(HttpStatusCode.OK);
+        invocation.StatusCode.ShouldBe(200);
+        invocation.FunctionError.ShouldBeNull();
+        invocation.ExecutedVersion.ShouldBe("$LATEST");
+
+        using var document = JsonDocument.Parse(responsePayload);
+
+        document.RootElement.ValueKind.ShouldBe(JsonValueKind.Object);
+        document.RootElement.ToString().ShouldNotContain("Sorry, something went wrong.");
+
+        document.RootElement.TryGetProperty("version", out var version).ShouldBeTrue();
+        version.GetString().ShouldBe("1.0");
+
+        document.RootElement.TryGetProperty("response", out var response).ShouldBeTrue();
+        response.TryGetProperty("shouldEndSession", out _).ShouldBeTrue();
+
+        if (response.TryGetProperty("outputSpeech", out var speech))
+        {
+            speech.TryGetProperty("type", out var type).ShouldBeTrue();
+            type.GetString().ShouldBe("SSML");
+
+            speech.TryGetProperty("ssml", out var ssml).ShouldBeTrue();
+            ssml.GetString().ShouldNotBeNullOrWhiteSpace();
+        }
+    }
+}
diff --git a/test/LondonTravel.Skill.EndToEndTests/SkillTests.cs b/test/LondonTravel.Skill.EndToEndTests/SkillTests.cs
index 37ab35b9..7e2bb95e 100644
--- a/test/LondonTravel.Skill.EndToEndTests/SkillTests.cs
+++ b/test/LondonTravel.Skill.EndToEndTests/SkillTests.cs
@@ -2,103 +2,251 @@
 // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.
 
 using System.Net;
+using System.Net.Http.Json;
 using System.Text.Json;
-using Amazon;
-using Amazon.Lambda;
-using Amazon.Lambda.Model;
+using System.Text.Json.Serialization;
 using LondonTravel.Skill.EndToEndTests;
 
 namespace MartinCostello.LondonTravel.Skill;
 
-[Collection(CloudWatchLogsFixtureCollection.Name)]
-public class SkillTests(CloudWatchLogsFixture fixture, ITestOutputHelper outputHelper)
+public class SkillTests(ITestOutputHelper outputHelper)
 {
-    public static IEnumerable<object[]> Payloads
+    [SkippableTheory]
+    [InlineData("Alexa, ask London Travel if there is any disruption today.")]
+    [InlineData("Alexa, ask London Travel about the Victoria line.")]
+    public async Task Can_Invoke_Skill_And_Get_Valid_Response(string content)
+    {
+        // Arrange
+        string functionName = TestConfiguration.FunctionName;
+        string regionName = TestConfiguration.RegionName;
+        string skillId = TestConfiguration.SkillId;
+        string stage = TestConfiguration.SkillStage;
+
+        Skip.If(string.IsNullOrEmpty(functionName), "No Lambda function name is configured.");
+        Skip.If(string.IsNullOrEmpty(regionName), "No AWS region name is configured.");
+        Skip.If(string.IsNullOrEmpty(skillId), "No skill ID is configured.");
+        Skip.If(string.IsNullOrEmpty(stage), "No skill stage is configured.");
+
+        using var client = await CreateHttpClientAsync();
+
+        await EnableSkillAsync(client, skillId, stage);
+
+        // Act
+        var simulation = await SimulateSkillAsync(client, skillId, stage, content);
+        simulation = await GetSimulationSkillAsync(client, skillId, stage, simulation);
+
+        // Assert
+        simulation.ShouldNotBeNull();
+        simulation.Result.ShouldNotBeNull();
+        simulation.Result.SkillExecutionInfo.ShouldNotBeNull();
+        simulation.Result.SkillExecutionInfo.Invocations.ShouldNotBeNull();
+        simulation.Result.SkillExecutionInfo.Invocations.ShouldNotBeEmpty();
+        simulation.Result.SkillExecutionInfo.Invocations.Count.ShouldBeGreaterThanOrEqualTo(1);
+
+        var invocation = simulation.Result.SkillExecutionInfo.Invocations[0];
+
+        invocation.InvocationRequest.ShouldNotBeNull();
+        invocation.InvocationRequest.RootElement.TryGetProperty("body", out _).ShouldBeTrue();
+        invocation.InvocationRequest.RootElement.TryGetProperty("endpoint", out var endpoint).ShouldBeTrue();
+
+        string endpointValue = endpoint.GetString();
+        endpointValue.ShouldStartWith($"arn:aws:lambda:{regionName}:");
+        endpointValue.ShouldEndWith($":function:{functionName}");
+
+        invocation.InvocationResponse.ShouldNotBeNull();
+        invocation.InvocationResponse.RootElement.TryGetProperty("body", out var body).ShouldBeTrue();
+        body.TryGetProperty("response", out var skillResponse).ShouldBeTrue();
+        skillResponse.TryGetProperty("outputSpeech", out var outputSpeech).ShouldBeTrue();
+
+        outputSpeech.TryGetProperty("type", out var speechType).ShouldBeTrue();
+        speechType.GetString().ShouldBe("SSML");
+
+        outputSpeech.TryGetProperty("ssml", out var ssml).ShouldBeTrue();
+        string speech = ssml.GetString();
+
+        speech.ShouldNotBeNullOrWhiteSpace();
+        speech.ShouldStartWith("<speak>");
+        speech.ShouldNotContain("Sorry, something went wrong.");
+    }
+
+    private static async Task<string> GenerateAccessTokenAsync()
     {
-        get
+        // To generate a new refresh token, run the following command:
+        // npm install -g ask-cli && ask util generate-lwa-tokens --client-id $CLIENT_ID --client-confirmation $CLIENT_SECRET --scopes "alexa::ask:skills:readwrite alexa::ask:skills:test"
+        string clientId = TestConfiguration.AlexaClientId;
+        string clientSecret = TestConfiguration.AlexaClientSecret;
+        string refreshToken = TestConfiguration.AlexaRefreshToken;
+
+        Skip.If(string.IsNullOrEmpty(clientId), "No client ID is configured.");
+        Skip.If(string.IsNullOrEmpty(clientSecret), "No client secret is configured.");
+        Skip.If(string.IsNullOrEmpty(refreshToken), "No refresh token is configured.");
+
+        // See https://developer.amazon.com/docs/login-with-amazon/authorization-code-grant.html#using-refresh-tokens
+        var parameters = new Dictionary<string, string>()
         {
-            return Directory.GetFiles("Payloads")
-                .Select(Path.GetFileNameWithoutExtension)
-                .Order()
-                .Select((p) => new object[] { p })
-                .ToArray();
-        }
+            ["client_id"] = clientId,
+            ["client_secret"] = clientSecret,
+            ["grant_type"] = "refresh_token",
+            ["refresh_token"] = refreshToken,
+        };
+
+        using var client = new HttpClient()
+        {
+            DefaultRequestHeaders =
+            {
+                UserAgent = { TestConfiguration.UserAgent },
+            },
+        };
+
+        using var content = new FormUrlEncodedContent(parameters);
+        using var response = await client.PostAsync(new Uri("https://api.amazon.com/auth/o2/token"), content);
+
+        response.EnsureSuccessStatusCode();
+
+        using var tokens = await response.Content.ReadFromJsonAsync<JsonDocument>();
+        return tokens.RootElement.GetProperty("access_token").GetString();
     }
 
-    [SkippableTheory]
-    [MemberData(nameof(Payloads))]
-    public async Task Can_Invoke_Intent_Can_Get_Json_Response(string payloadName)
+    private static async Task<HttpClient> CreateHttpClientAsync()
     {
-        var credentials = AwsConfiguration.GetCredentials();
+        string token = await GenerateAccessTokenAsync();
 
-        Skip.If(credentials is null, "No AWS credentials are configured.");
+        var client = new HttpClient()
+        {
+            BaseAddress = new Uri("https://api.amazonalexa.com", UriKind.Absolute),
+            DefaultRequestHeaders =
+            {
+                Authorization = new("Bearer", token),
+                UserAgent = { TestConfiguration.UserAgent },
+            },
+        };
 
-        string functionName = AwsConfiguration.FunctionName;
-        string regionName = AwsConfiguration.RegionName;
+        return client;
+    }
 
-        Skip.If(string.IsNullOrEmpty(functionName), "No Lambda function name is configured.");
-        Skip.If(string.IsNullOrEmpty(regionName), "No AWS region name is configured.");
+    private static async Task EnableSkillAsync(HttpClient client, string skillId, string stage)
+    {
+        // See https://developer.amazon.com/en-US/docs/alexa/smapi/skill-enablement.html#enable-skill
+        using var response = await client.PutAsJsonAsync($"v1/skills/{skillId}/stages/{stage}/enablement", new { });
 
-        // Arrange
-        string payload = await File.ReadAllTextAsync(Path.Combine("Payloads", $"{payloadName}.json"));
+        response.StatusCode.ShouldBe(HttpStatusCode.NoContent, $"Failed to enable skill for stage {stage}.");
+        response.EnsureSuccessStatusCode();
+    }
 
-        var region = RegionEndpoint.GetBySystemName(regionName);
+    private static async Task<SimulationResponse> GetSimulationSkillAsync(
+        HttpClient client,
+        string skillId,
+        string stage,
+        SimulationResponse simulation)
+    {
+        string simulationId = simulation.Id;
 
-        using var client = new AmazonLambdaClient(credentials, region);
+        const string InProgress = "IN_PROGRESS";
 
-        var request = new InvokeRequest()
+        if (simulation.Status is InProgress)
         {
-            FunctionName = functionName,
-            InvocationType = InvocationType.RequestResponse,
-            LogType = LogType.None,
-            Payload = payload,
+            // Poll for a response to be available
+            var delay = TimeSpan.FromSeconds(2);
+
+            for (int i = 0; i < 5; i++)
+            {
+                await Task.Delay(delay);
+
+                // See https://developer.amazon.com/en-US/docs/alexa/smapi/skill-simulation-api.html#get-simulation-result
+                simulation = await client.GetFromJsonAsync<SimulationResponse>($"v2/skills/{skillId}/stages/{stage}/simulations/{simulationId}");
+                simulation.ShouldNotBeNull();
+
+                if (simulation.Status is not InProgress)
+                {
+                    break;
+                }
+            }
+        }
+
+        simulation.ShouldNotBeNull();
+        simulation.Id.ShouldBe(simulationId);
+        simulation.Status.ShouldBe("SUCCESSFUL", $"Code: {simulation.Result?.Code}; Result: {simulation.Result?.Message}");
+
+        return simulation;
+    }
+
+    private async Task<SimulationResponse> SimulateSkillAsync(
+        HttpClient client,
+        string skillId,
+        string stage,
+        string content)
+    {
+        // See https://developer.amazon.com/en-US/docs/alexa/smapi/skill-simulation-api.html#simulate-skill
+        var request = new
+        {
+            session = new { mode = "DEFAULT" },
+            input = new { content },
+            device = new { locale = "en-GB" },
         };
 
-        outputHelper.WriteLine($"FunctionName: {request.FunctionName}");
-        outputHelper.WriteLine($"Payload: {request.Payload}");
+        using var response = await client.PostAsJsonAsync($"v2/skills/{skillId}/stages/{stage}/simulations", request);
 
-        // Act
-        InvokeResponse invocation = await client.InvokeAsync(request);
+        string requestId = response.Headers.GetValues("x-amzn-requestid").FirstOrDefault();
+        outputHelper.WriteLine($"Request Id: {requestId}");
 
-        // Assert
-        invocation.ShouldNotBeNull();
-        invocation.ResponseMetadata.ShouldNotBeNull();
+        response.StatusCode.ShouldBe(HttpStatusCode.OK);
 
-        fixture.Requests[invocation.ResponseMetadata.RequestId] = payloadName;
+        var simulation = await response.Content.ReadFromJsonAsync<SimulationResponse>();
 
-        using var reader = new StreamReader(invocation.Payload);
-        string responsePayload = await reader.ReadToEndAsync();
+        simulation.ShouldNotBeNull();
 
-        outputHelper.WriteLine($"ExecutedVersion: {invocation.ExecutedVersion}");
-        outputHelper.WriteLine($"FunctionError: {invocation.FunctionError}");
-        outputHelper.WriteLine($"HttpStatusCode: {invocation.HttpStatusCode}");
-        outputHelper.WriteLine($"RequestId: {invocation.ResponseMetadata.RequestId}");
-        outputHelper.WriteLine($"StatusCode: {invocation.StatusCode}");
-        outputHelper.WriteLine($"Payload: {responsePayload}");
+        outputHelper.WriteLine($"Simulation ID: {simulation.Id}");
 
-        invocation.HttpStatusCode.ShouldBe(HttpStatusCode.OK);
-        invocation.StatusCode.ShouldBe(200);
-        invocation.FunctionError.ShouldBeNull();
-        invocation.ExecutedVersion.ShouldBe("$LATEST");
+        return simulation;
+    }
 
-        using var document = JsonDocument.Parse(responsePayload);
+    private sealed class SimulationRequest
+    {
+        public string Content { get; set; }
 
-        document.RootElement.ValueKind.ShouldBe(JsonValueKind.Object);
-        document.RootElement.ToString().ShouldNotContain("Sorry, something went wrong.");
+        public string Locale { get; set; }
 
-        document.RootElement.TryGetProperty("version", out var version).ShouldBeTrue();
-        version.GetString().ShouldBe("1.0");
+        public string SkillId { get; set; }
 
-        document.RootElement.TryGetProperty("response", out var response).ShouldBeTrue();
-        response.TryGetProperty("shouldEndSession", out _).ShouldBeTrue();
+        public string Stage { get; set; }
+    }
 
-        if (response.TryGetProperty("outputSpeech", out var speech))
-        {
-            speech.TryGetProperty("type", out var type).ShouldBeTrue();
-            type.GetString().ShouldBe("SSML");
+    private sealed class SimulationResponse
+    {
+        [JsonPropertyName("id")]
+        public string Id { get; set; }
 
-            speech.TryGetProperty("ssml", out var ssml).ShouldBeTrue();
-            ssml.GetString().ShouldNotBeNullOrWhiteSpace();
-        }
+        [JsonPropertyName("status")]
+        public string Status { get; set; }
+
+        [JsonPropertyName("result")]
+        public SimulationResult Result { get; set; }
+    }
+
+    private sealed class SimulationResult
+    {
+        [JsonPropertyName("message")]
+        public string Message { get; set; }
+
+        [JsonPropertyName("code")]
+        public string Code { get; set; }
+
+        [JsonPropertyName("skillExecutionInfo")]
+        public SkillExecutionInfo SkillExecutionInfo { get; set; }
+    }
+
+    private sealed class SkillExecutionInfo
+    {
+        [JsonPropertyName("invocations")]
+        public IList<SkillInvocation> Invocations { get; set; }
+    }
+
+    private sealed class SkillInvocation
+    {
+        [JsonPropertyName("invocationRequest")]
+        public JsonDocument InvocationRequest { get; set; }
+
+        [JsonPropertyName("invocationResponse")]
+        public JsonDocument InvocationResponse { get; set; }
     }
 }
diff --git a/test/LondonTravel.Skill.EndToEndTests/AwsConfiguration.cs b/test/LondonTravel.Skill.EndToEndTests/TestConfiguration.cs
similarity index 52%
rename from test/LondonTravel.Skill.EndToEndTests/AwsConfiguration.cs
rename to test/LondonTravel.Skill.EndToEndTests/TestConfiguration.cs
index 2d31d447..f8ea80fe 100644
--- a/test/LondonTravel.Skill.EndToEndTests/AwsConfiguration.cs
+++ b/test/LondonTravel.Skill.EndToEndTests/TestConfiguration.cs
@@ -1,16 +1,30 @@
 // Copyright (c) Martin Costello, 2017. All rights reserved.
 // Licensed under the Apache 2.0 license. See the LICENSE file in the project root for full license information.
 
+using System.Net.Http.Headers;
+using System.Reflection;
 using Amazon.Runtime;
 
 namespace LondonTravel.Skill.EndToEndTests;
 
-internal static class AwsConfiguration
+internal static class TestConfiguration
 {
+    public static string AlexaClientId => Environment.GetEnvironmentVariable("LWA_CLIENT_ID");
+
+    public static string AlexaClientSecret => Environment.GetEnvironmentVariable("LWA_CLIENT_SECRET");
+
+    public static string AlexaRefreshToken => Environment.GetEnvironmentVariable("LWA_REFRESH_TOKEN");
+
     public static string FunctionName => Environment.GetEnvironmentVariable("LAMBDA_FUNCTION_NAME");
 
     public static string RegionName => Environment.GetEnvironmentVariable("AWS_REGION");
 
+    public static string SkillId => Environment.GetEnvironmentVariable("SKILL_ID");
+
+    public static string SkillStage => Environment.GetEnvironmentVariable("SKILL_STAGE");
+
+    public static ProductInfoHeaderValue UserAgent { get; } = new("LondonTravel.Skill.EndToEndTests", typeof(TestConfiguration).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>().InformationalVersion);
+
     public static AWSCredentials GetCredentials()
     {
         try