Build a Serverless Ticket Router
Deploy a serverless ML app that routes support tickets through an AWS API.
Introduction
30 Second Summary
A busy support inbox fills up faster than a team can sort it. Every ticket waiting for the right queue adds time before someone can help.
In this project, you will train a Multinomial Naive Bayes model that routes IT support tickets to account access, billing, or technical support. You will deploy it as a serverless HTTPS application on AWS.
What You'll Build
Picture opening your Amazon CloudFront URL, pasting a locked-account ticket, and seeing Account access appear with a confidence score plus the recognized terms.
By the end of this project, you'll have:
- A three-category ticket router that classifies new requests as Account access, Billing, or Technical issue with a visible confidence score.
- An HTTPS classification API that receives browser requests through Amazon API Gateway and runs lightweight inference in AWS Lambda.
- A public support form delivered through Amazon CloudFront while its files remain private in Amazon S3.
- Secret Mission: Train a second classifier that labels each ticket as Low, Medium, or High urgency with its own confidence score.
Are there any prerequisites?
You need an active AWS account with billing access plus permission to create cloud resources. Basic experience with the AWS CLI helps, but the Windows setup and verification steps are included.
Before We Start
This is your moment to commit to what you are building before the hands-on work begins. You will create a serverless router that helps support teams send IT tickets to the right queue.
Prepare Your Windows Workstation
Tool mismatches can hide the machine-learning workflow behind installation problems. Credential errors can obscure the cloud workflow in the same way.
This step gives your router a predictable Windows base. You will confirm each local tool before creating an isolated workspace for the project.
In this step, get ready to:
- Confirm the four required local tools.
- Build an isolated local workspace.
- Verify your AWS identity.
Install and verify your local tools
Your workstation needs Visual Studio Code for editing. It also needs Python for local training.
The AWS CLI connects your terminal to your AWS account. The AWS SAM CLI builds the serverless application before deployment.
- Press the Windows key to open Windows search.
- Type Visual Studio Code into the search box.
- Press Enter.
✔️ Visual Studio Code opens
Visual Studio Code is ready to edit the router workspace. That is your first local tool confirmed.
ⓧ Visual Studio Code is not installed
Install Visual Studio Code with the recommended Windows User setup installer.
- Visit the Visual Studio Code Windows setup page.
- Download the User setup installer.
- Run the downloaded installer.
- Follow the installer prompts.
- Repeat the Windows search steps above.
- Press the Windows key to open Windows search.
- Type PowerShell into the search box.
- Press Enter to open PowerShell.
- Check your Python version by running this command:
python --version
What Does This Check Do?
The command asks the active Python installation to report its version. This project uses Python 3.14 for local training.
✔️ I see Python 3.14
Your Python version matches the project runtime. Keep this PowerShell window open for the next checks.
ⓧ I see an older Python version
Install Python 3.14.8 before creating the project environment.
- Visit the official Python downloads page for Windows.
- Download the Windows installer for Python 3.14.8.
- Run the downloaded installer.
- Follow the installer prompts.
- Close PowerShell.
- Reopen PowerShell through Windows search.
- Repeat the Python version check above.
ⓧ Python is not found
Python is missing from your workstation or unavailable from PowerShell.
- Visit the official Python downloads page for Windows.
- Download the Windows installer for Python 3.14.8.
- Run the downloaded installer.
- Follow the installer prompts.
- Close PowerShell.
- Reopen PowerShell through Windows search.
- Repeat the Python version check above.
- Check your AWS CLI version by running this command:
aws --version
What Does This Check Do?
The command reports the installed AWS CLI version. This project requires AWS CLI v2.
✔️ I see AWS CLI v2
Your AWS CLI version is ready for authentication. You will verify its active credentials later in this step.
ⓧ I see AWS CLI v1
Upgrade to AWS CLI v2 before configuring credentials.
- Visit the official AWS CLI installation guide.
- Download the Windows installer for AWS CLI v2.
- Run the downloaded installer.
- Follow the installer prompts.
- Close PowerShell.
- Reopen PowerShell through Windows search.
- Repeat the AWS CLI version check above.
ⓧ The aws command is not found
Install AWS CLI v2 with the official Windows installer.
- Visit the official AWS CLI installation guide.
- Download the Windows installer for AWS CLI v2.
- Run the downloaded installer.
- Follow the installer prompts.
- Close PowerShell.
- Reopen PowerShell through Windows search.
- Repeat the AWS CLI version check above.
- Check your AWS SAM CLI version by running this command:
sam --version
What Does This Check Do?
The command reports the installed AWS SAM CLI version. This project uses AWS SAM CLI 1.169.0.
✔️ I see AWS SAM CLI 1.169.0
Your AWS SAM CLI version matches the project. All four local tools are ready.
ⓧ I see another SAM CLI version
Install AWS SAM CLI 1.169.0 so the project follows the tested command flow.
- Visit the AWS SAM CLI release page.
- Download AWS_SAM_CLI_64_PY3.msi.
- Run the downloaded installer.
- Follow the installer prompts.
- Close PowerShell.
- Reopen PowerShell through Windows search.
- Repeat the AWS SAM CLI version check above.
ⓧ The sam command is not found
Install the 64-bit AWS SAM CLI MSI before continuing.
- Visit the AWS SAM CLI release page.
- Download AWS_SAM_CLI_64_PY3.msi.
- Run the downloaded installer.
- Follow the installer prompts.
- Close PowerShell.
- Reopen PowerShell through Windows search.
- Repeat the AWS SAM CLI version check above.
Still Missing a Command?
- Close every open PowerShell window after an installation so the updated command path can load.
- Confirm that you downloaded the 64-bit Windows MSI for AWS SAM CLI.
If a command is still unavailable, help me diagnose the missing Windows command.
Create and prepare your workspace
A dedicated folder keeps the router files together. A virtual environment keeps this project's Python packages separate from other projects.
- Return to the PowerShell window from earlier.
- Create the support-ticket-router folder on your Desktop by running these commands:
Set-Location -Path "$HOME\Desktop"
New-Item -Path "support-ticket-router" -ItemType Directory
Set-Location -Path "support-ticket-router"
code .
What Do These Commands Do?
- The first Set-Location command moves PowerShell to your Desktop.
- The New-Item command creates the support-ticket-router folder.
- The second Set-Location command moves PowerShell into that folder.
- The final command opens the current folder in Visual Studio Code.
You will see Visual Studio Code open with support-ticket-router as the workspace. The empty folder is now ready for its isolated Python environment.
Workspace Did Not Open?
- Close PowerShell if Visual Studio Code was installed while that window was open.
- Reopen PowerShell so the Visual Studio Code command becomes available.
- Confirm that your PowerShell path ends with support-ticket-router before retrying the final command.
If the folder still does not open, help me open my workspace from PowerShell.
- Open a PowerShell terminal from Visual Studio Code's top terminal menu.
- Create .venv by running this command:
python -m venv .venv
What Does This Command Do?
Python's venv module creates an isolated interpreter inside .venv. Packages installed there stay scoped to this router.
- Look for .venv in the Visual Studio Code Explorer.
You should see the .venv folder inside support-ticket-router.
Virtual Environment Missing?
- Confirm that the terminal path ends with support-ticket-router.
- Confirm that the earlier Python check reported version 3.14.
If the folder is still missing, help me create the virtual environment.
- Activate the virtual environment by running this command:
.venv\Scripts\activate
What Does Activation Change?
Activation makes the python command use the interpreter inside .venv. Future package installations now belong to this workspace.
Your terminal prompt should now show the virtual environment name. This confirms that new Python packages stay inside the project.
Environment Did Not Activate?
- Confirm that .venv exists in the Visual Studio Code Explorer.
- Confirm that your terminal is inside support-ticket-router.
If activation still fails, help me activate .venv on Windows.
The training script in the next step uses scikit-learn to learn ticket categories. Pinning version 1.9.1 keeps that training environment repeatable.
- Install the pinned dependency by running this command:
python -m pip install scikit-learn==1.9.1
What Does This Command Do?
The command uses the active environment's Python interpreter to run pip. The exact version pin prevents the dependency from changing between runs.
The installation may take a minute while Python downloads the package. Let the command finish before using the terminal again.
The terminal should finish without an error. Its completion output should include scikit-learn 1.9.1 among the installed packages.
Dependency Installation Failed?
- Confirm that the terminal prompt shows the active .venv environment.
- Confirm that the earlier Python check reported version 3.14.
- Check that your internet connection can reach the Python package registry.
If the package still fails to install, help me troubleshoot the pinned scikit-learn installation.
Connect AWS to the project workspace
The AWS CLI needs an authenticated profile before it can deploy cloud resources. The identity check below confirms which AWS account your terminal can access.
Keep Your Credentials Private
The configuration command asks for your AWS credentials. Enter them only into your local terminal.
It also asks for your preferred AWS Region. The command finishes by asking for your output settings.
- Start the interactive AWS CLI configuration by running this command:
aws configure
What Does This Command Do?
The command prompts for credentials. It also records your Region preference plus your output settings for the active AWS CLI profile.
The next command tests the completed profile against AWS.
Before you check, which AWS account do you expect the active profile to identify?
- Verify the active AWS identity by running this command:
aws sts get-caller-identity
What Should You See?
The response includes UserId. It also includes Account plus Arn for the active credentials.
That response proves your terminal can authenticate with AWS. Your deployment commands now have an account identity to use.
Your local AWS profile is working. The workstation can now make authenticated requests for this project.
Identity Check Failed?
- Repeat the configuration command if the profile has no credentials.
- Check that the credentials belong to the AWS account you plan to use.
- Confirm that your computer can reach AWS over the internet.
If authentication still fails, help me troubleshoot my active AWS CLI profile.
The project structure separates the cloud function from the browser interface. The dependency file records the package needed to train the model locally.
- Create the lambda folder by running the commands below:
- Create the frontend folder with the same commands:
- Create an empty requirements.txt file with the same commands:
New-Item -Path "lambda", "frontend" -ItemType Directory
New-Item -Path "requirements.txt" -ItemType File
What Did These Commands Create?
- The lambda folder will hold the portable model plus the cloud inference code.
- The frontend folder will hold the browser interface.
- The requirements.txt file records the local training dependency.
You should now see lambda, frontend, plus requirements.txt in the Visual Studio Code Explorer.
- Select requirements.txt in the Visual Studio Code Explorer.
- Add the pinned dependency by pasting this line:
scikit-learn==1.9.1
Why Record the Dependency?
The dependency file makes the required scikit-learn version visible outside the current environment. A future installation can recreate the same package choice.
- Press Ctrl+S to save requirements.txt.
- Confirm that the editor contains the exact pinned dependency.
The workspace now records scikit-learn 1.9.1 as its training dependency.
Project Files Missing?
- Confirm that the integrated terminal path ends with support-ticket-router.
- Refresh the Visual Studio Code Explorer if the folders do not appear immediately.
- Confirm that requirements.txt is saved inside support-ticket-router.
If the structure still differs, help me repair my project workspace.
✔️ Awesome, I've got everything!
Your dependency file matches the project. Make sure requirements.txt is saved before continuing.
ⓧ I'd like to double check the full code
scikit-learn==1.9.1
What Should Match?
Your complete requirements.txt file contains this single version pin. Compare the spelling plus the version number exactly.
Before the final check, what do you expect each command to confirm about your workstation?
- Verify the complete setup by running these commands:
python --version
aws --version
sam --version
aws sts get-caller-identity
What Should You See?
The first three commands report Python 3.14, AWS CLI v2, plus AWS SAM CLI 1.169.0.
The final command returns your AWS account identity. Together these results prove that the local toolchain plus cloud authentication are ready.
Your Windows workstation is ready for local training plus AWS deployment. Next up, you will train the first ticket-routing model and export its learned parameters.
Train the Ticket Router
Your Windows workspace is ready for the first working version of the router. The next goal is to turn labeled support tickets into visible predictions with scikit-learn.
Before adding cloud infrastructure, you need a classifier whose learned parameters can leave the training runtime. You will train a Multinomial Naive Bayes model locally. You will export its parameters as a portable JSON artifact.
In this step, get ready to:
- Create a labeled ticket dataset with one shared tokenization rule.
- Train a category classifier with binary text features.
- Export the model parameters and inspect three predictions.
Add labeled examples and one tokenizer
A classifier learns from examples paired with known labels. Your dataset covers account access requests, billing questions, and technical issues.
- In the Explorer sidebar for support-ticket-router, select the icon for creating a file.
- Name the file train_model.py.
- Add the training imports by copying this code into train_model.py:
import json
import re
from pathlib import Path
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
What do these imports provide?
- The json module turns the learned parameters into a portable model artifact.
- The re module applies one tokenization rule to every ticket.
- The Path class creates the model file in the existing lambda folder.
- The two scikit-learn classes create features and train the classifier.
- Save train_model.py.
- Confirm train_model.py now appears beside requirements.txt in the Explorer sidebar.
Don't see the new file?
Check that the Explorer sidebar is showing the support-ticket-router workspace. Create train_model.py inside that workspace.
Still stuck? Help me create train_model.py in the correct workspace.
Each label needs enough varied examples for the model to associate useful words with that support queue. The balanced dataset below gives every category eight examples.
- Place the labeled dataset below the imports in train_model.py by copying this code:
CATEGORY_DATA = [
("I forgot my password and cannot sign in", "account_access"),
("My account is locked after too many login attempts", "account_access"),
("The MFA code is not working for my login", "account_access"),
("Please reset my password", "account_access"),
("I need permission to access the dashboard", "account_access"),
("My user account was disabled", "account_access"),
("Access is denied when I open the admin page", "account_access"),
("I cannot remember my username", "account_access"),
("I was charged twice for this month", "billing"),
("Please send me a copy of my invoice", "billing"),
("I need a refund for the last payment", "billing"),
("How can I cancel my subscription", "billing"),
("My credit card payment failed", "billing"),
("Where can I download my receipt", "billing"),
("The renewal price is incorrect", "billing"),
("My subscription charge is missing a discount", "billing"),
("The application crashes when I upload a file", "technical_issue"),
("The API returns a server error", "technical_issue"),
("File upload keeps failing", "technical_issue"),
("The dashboard shows a blank screen", "technical_issue"),
("Requests to the API time out", "technical_issue"),
("The search feature is not working", "technical_issue"),
("The website is extremely slow", "technical_issue"),
("There is a bug in the mobile application", "technical_issue"),
]
How is the dataset structured?
- Each tuple contains one ticket followed by its correct category.
- The label account_access covers sign-in problems and permission requests.
- The label billing covers payments and subscription questions.
- The label technical_issue covers application faults and service failures.
- Save train_model.py.
- Confirm the first dataset records use account_access.
- Confirm the final dataset records use technical_issue.
Seeing a syntax warning in the dataset?
Check that every ticket and label remains inside quotes. Confirm that every tuple ends with a comma.
Need a second pair of eyes? Help me find the syntax problem in CATEGORY_DATA.
The test tickets stay outside the training dataset. They show how the classifier handles sentences it did not memorize.
- Place the test tickets and tokenizer below CATEGORY_DATA by copying this code:
TEST_TICKETS = [
"I am locked out after changing my password",
"Can you refund the duplicate subscription charge",
"The API upload endpoint keeps timing out",
]
def tokenize(text):
return re.findall(r"[a-z0-9']+", text.lower())
Why share one tokenizer?
The tokenize() function converts every ticket to lowercase words. The same rule processes training examples and unseen tickets.
Consistent tokenization keeps each word tied to the same feature during training and prediction. That shared rule makes the exported vocabulary reusable.
- Save train_model.py.
- Confirm TEST_TICKETS contains three unseen examples.
- Confirm tokenize() converts its input to lowercase.
Tokenizer looks different?
Compare the regular expression character by character. A missing quote or bracket changes which terms the model recognizes.
Need help checking it? Help me compare my tokenize function with the required version.
Train and export the category model
Text vectorization turns recognized words into numeric features. CountVectorizer records whether each vocabulary term appears in a ticket.
Why export learned parameters?
The cloud inference code only needs the learned classes, vocabulary, priors, and feature probabilities. Exporting those values keeps the future Lambda package independent from the scikit-learn training runtime.
- Place the training function below tokenize() by copying this code:
def train_classifier(records):
texts = [text for text, _ in records]
labels = [label for _, label in records]
vectorizer = CountVectorizer(
tokenizer=tokenize,
token_pattern=None,
lowercase=False,
binary=True,
)
features = vectorizer.fit_transform(texts)
classifier = MultinomialNB(alpha=1.0)
classifier.fit(features, labels)
artifact = {
"classes": classifier.classes_.tolist(),
"vocabulary": vectorizer.vocabulary_,
"class_log_prior": classifier.class_log_prior_.tolist(),
"feature_log_prob": classifier.feature_log_prob_.tolist(),
}
return classifier, vectorizer, artifact
What does this function learn?
- The texts and labels lists separate each ticket from its known category.
- The CountVectorizer(binary=True) configuration records each recognized term as present or absent.
- The MultinomialNB(alpha=1.0) classifier learns a score for every category and vocabulary term.
- The artifact dictionary preserves the learned values needed for inference.
- Save train_model.py.
- Confirm train_classifier() returns the classifier, vectorizer, and artifact.
Training function shows an indentation problem?
Keep every statement from texts through the return line indented inside train_classifier().
Still seeing a warning? Help me check the indentation in train_classifier.
The entry point connects training to the portable artifact. It also runs each unseen ticket through the fitted classifier.
- Place the program entry point below train_classifier() by copying this code:
def main():
classifier, vectorizer, artifact = train_classifier(CATEGORY_DATA)
output_path = Path("lambda/model.json")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps({"category_model": artifact}, indent=2),
encoding="utf-8",
)
print(f"Wrote {output_path}")
for ticket in TEST_TICKETS:
features = vectorizer.transform([ticket])
category = classifier.predict(features)[0]
confidence = classifier.predict_proba(features).max()
print(f"{category:16} {confidence:.1%} {ticket}")
if __name__ == "__main__":
main()
What does the entry point produce?
- The first line trains the category classifier with CATEGORY_DATA.
- The file-writing section stores category_model in lambda/model.json using UTF-8 encoding.
- The loop transforms each unseen ticket with the fitted vectorizer.
- The printed rows show the predicted category and confidence percentage.
- Save train_model.py.
- Confirm the final two lines call main() when you run the file directly.
Entry point does not line up?
Keep the training, file-writing, and prediction statements indented inside main(). Keep the final main() call indented inside its condition.
Need help before running it? Help me check the structure of my main function.
✔️ Awesome, I've got everything!
Your training script is assembled. Save train_model.py before running it.
ⓧ I'd like to double check the full code
Compare your file with this complete version. Every dataset entry and indentation level should match.
import json
import re
from pathlib import Path
from sklearn.feature_extraction.text import CountVectorizer
from sklearn.naive_bayes import MultinomialNB
CATEGORY_DATA = [
("I forgot my password and cannot sign in", "account_access"),
("My account is locked after too many login attempts", "account_access"),
("The MFA code is not working for my login", "account_access"),
("Please reset my password", "account_access"),
("I need permission to access the dashboard", "account_access"),
("My user account was disabled", "account_access"),
("Access is denied when I open the admin page", "account_access"),
("I cannot remember my username", "account_access"),
("I was charged twice for this month", "billing"),
("Please send me a copy of my invoice", "billing"),
("I need a refund for the last payment", "billing"),
("How can I cancel my subscription", "billing"),
("My credit card payment failed", "billing"),
("Where can I download my receipt", "billing"),
("The renewal price is incorrect", "billing"),
("My subscription charge is missing a discount", "billing"),
("The application crashes when I upload a file", "technical_issue"),
("The API returns a server error", "technical_issue"),
("File upload keeps failing", "technical_issue"),
("The dashboard shows a blank screen", "technical_issue"),
("Requests to the API time out", "technical_issue"),
("The search feature is not working", "technical_issue"),
("The website is extremely slow", "technical_issue"),
("There is a bug in the mobile application", "technical_issue"),
]
TEST_TICKETS = [
"I am locked out after changing my password",
"Can you refund the duplicate subscription charge",
"The API upload endpoint keeps timing out",
]
def tokenize(text):
return re.findall(r"[a-z0-9']+", text.lower())
def train_classifier(records):
texts = [text for text, _ in records]
labels = [label for _, label in records]
vectorizer = CountVectorizer(
tokenizer=tokenize,
token_pattern=None,
lowercase=False,
binary=True,
)
features = vectorizer.fit_transform(texts)
classifier = MultinomialNB(alpha=1.0)
classifier.fit(features, labels)
artifact = {
"classes": classifier.classes_.tolist(),
"vocabulary": vectorizer.vocabulary_,
"class_log_prior": classifier.class_log_prior_.tolist(),
"feature_log_prob": classifier.feature_log_prob_.tolist(),
}
return classifier, vectorizer, artifact
def main():
classifier, vectorizer, artifact = train_classifier(CATEGORY_DATA)
output_path = Path("lambda/model.json")
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(
json.dumps({"category_model": artifact}, indent=2),
encoding="utf-8",
)
print(f"Wrote {output_path}")
for ticket in TEST_TICKETS:
features = vectorizer.transform([ticket])
category = classifier.predict(features)[0]
confidence = classifier.predict_proba(features).max()
print(f"{category:16} {confidence:.1%} {ticket}")
if __name__ == "__main__":
main()
What should match?
Your complete file should define CATEGORY_DATA, TEST_TICKETS, tokenize(), train_classifier(), and main() in this order.
Run three unseen tickets
Before you run this, which category do you expect the model to choose for each unseen ticket?
- Train the classifier and generate its model artifact by running this command in the activated terminal:
python train_model.py
How should you read the output?
The first line confirms that the model artifact was written inside the lambda folder. The next three rows show one prediction and confidence percentage for each unseen ticket.
You should see predictions for account_access, billing, and technical_issue. Those rows prove that all three routing paths are active.
You have your first visible classifier. It now routes unseen tickets across all three support categories and preserves its learned parameters for cloud inference.
- Expand the lambda folder in the Explorer sidebar.
- Select model.json.
- Confirm category_model contains classes, vocabulary, class_log_prior, and feature_log_prob.
Training script did not complete?
- Check that the terminal is still using the activated .venv environment if Python cannot import the installed dependency.
- Check that the terminal path ends with support-ticket-router if Python cannot find the script.
- Compare the indentation in train_classifier() and main() if Python reports a syntax problem.
Still blocked? Help me diagnose why train_model.py does not produce all three predictions and lambda/model.json.
Your trained parameters are ready to leave scikit-learn behind. Next, you will load the JSON artifact with standard Python and place the router behind a browser interface.
Build the Local Inference App
Your trained scikit-learn model can classify tickets locally. However, its predictions currently exist only inside the training script.
In this step, you will turn the exported JSON parameters into a portable inference module. You will connect that module to a cloud-ready handler before building the browser interface people can use.
In this step, get ready to:
- Reproduce the trained classifier with the Python standard library.
- Build a handler for ticket classification requests.
- Create the support ticket form and verify its local deployment message.
Build the portable inference module
The exported model contains the vocabulary plus the probabilities learned during training. A small Python module can load those values and calculate predictions without importing scikit-learn.
Why export the learned parameters?
The trained parameters contain everything inference needs. Shipping those values keeps the cloud function focused on scoring tickets.
This separation also leaves the training dependency on your workstation. The deployed function uses the managed Python runtime and its standard library.
- Create inference.py inside the existing lambda folder in your VS Code workspace.
- Add the model loader and shared tokenizer by pasting this code into lambda\inference.py:
import json
import math
import re
from pathlib import Path
MODEL = json.loads(
Path(__file__).with_name("model.json").read_text(encoding="utf-8")
)
def tokenize(text):
return re.findall(r"[a-z0-9']+", text.lower())
What does this code do?
- The MODEL value loads model.json from the same folder as the module.
- The tokenize() function applies the same lowercase word rule used during training.
- Matching the training tokenizer keeps each recognized word aligned with the exported vocabulary index.
- Save lambda\inference.py.
- Check the model-loading code for valid Python syntax by running:
python -m py_compile lambda\inference.py
What should you see?
A successful compilation returns to the PowerShell prompt without printing an error. This confirms that Python can parse the module.
Seeing a compilation error?
- Confirm that each opening parenthesis around json.loads() has a matching closing parenthesis.
- Check that model.json remains inside the same lambda folder as inference.py.
Need a second pair of eyes? Help me troubleshoot my inference module.
The scorer recreates the calculation behind Multinomial Naive Bayes. It adds each class prior to the learned probability for every recognized term.
- Add score_model() below tokenize() in lambda\inference.py by pasting:
def score_model(model_data, text):
vocabulary = model_data["vocabulary"]
recognized_terms = sorted(
{token for token in tokenize(text) if token in vocabulary}
)
if not recognized_terms:
raise ValueError("No known support terms were found in the ticket.")
feature_indices = [vocabulary[token] for token in recognized_terms]
scores = []
for class_index, class_prior in enumerate(model_data["class_log_prior"]):
feature_score = sum(
model_data["feature_log_prob"][class_index][feature_index]
for feature_index in feature_indices
)
scores.append(class_prior + feature_score)
largest_score = max(scores)
exponentials = [math.exp(score - largest_score) for score in scores]
total = sum(exponentials)
probabilities = [value / total for value in exponentials]
best_index = max(range(len(probabilities)), key=probabilities.__getitem__)
return {
"label": model_data["classes"][best_index],
"confidence": round(probabilities[best_index], 4),
"recognized_terms": recognized_terms,
}
How does scoring work?
- The vocabulary filters the ticket down to terms learned during training.
- Each vocabulary index selects the matching feature probability for every class.
- The exponential calculation converts the log scores into probabilities that add up to one.
- The returned dictionary keeps the winning label beside its confidence and recognized terms.
- Save lambda\inference.py.
- Compile the expanded scorer by running:
python -m py_compile lambda\inference.py
What does this check prove?
A quiet return confirms that the full scoring function has valid Python syntax. The module is ready for its public prediction function.
Scorer not compiling?
- Check the indentation inside the for loop.
- Confirm that the returned dictionary closes with a brace.
- Compare the spellings of class_log_prior and feature_log_prob with model.json.
Still blocked? Help me diagnose the scoring function.
The public prediction function validates the incoming ticket before passing it to the scorer. It also reshapes the scorer output into the response fields used by the web interface.
- Add predict() below score_model() in lambda\inference.py by pasting:
def predict(text):
if not isinstance(text, str) or not text.strip():
raise ValueError("Ticket text is required.")
result = score_model(MODEL["category_model"], text)
return {
"category": result["label"],
"confidence": result["confidence"],
"recognized_terms": result["recognized_terms"],
}
What does predict return?
The function rejects missing ticket text before scoring starts. A valid ticket returns its category plus a rounded confidence value and the vocabulary terms used by the model.
- Save lambda\inference.py.
- Compile the completed inference module by running:
python -m py_compile lambda\inference.py
What should happen?
PowerShell returns without an error when the completed module is valid. Your exported model now has a standard-library inference layer.
Prediction function not compiling?
- Confirm that predict() sits outside score_model().
- Check that MODEL["category_model"] uses matching quotation marks.
Need help comparing the functions? Help me fix lambda/inference.py.
✔️ Awesome, I've got everything!
Great. Save lambda\inference.py before moving to the request handler.
ⓧ I'd like to double check the full code
import json
import math
import re
from pathlib import Path
MODEL = json.loads(
Path(__file__).with_name("model.json").read_text(encoding="utf-8")
)
def tokenize(text):
return re.findall(r"[a-z0-9']+", text.lower())
def score_model(model_data, text):
vocabulary = model_data["vocabulary"]
recognized_terms = sorted(
{token for token in tokenize(text) if token in vocabulary}
)
if not recognized_terms:
raise ValueError("No known support terms were found in the ticket.")
feature_indices = [vocabulary[token] for token in recognized_terms]
scores = []
for class_index, class_prior in enumerate(model_data["class_log_prior"]):
feature_score = sum(
model_data["feature_log_prob"][class_index][feature_index]
for feature_index in feature_indices
)
scores.append(class_prior + feature_score)
largest_score = max(scores)
exponentials = [math.exp(score - largest_score) for score in scores]
total = sum(exponentials)
probabilities = [value / total for value in exponentials]
best_index = max(range(len(probabilities)), key=probabilities.__getitem__)
return {
"label": model_data["classes"][best_index],
"confidence": round(probabilities[best_index], 4),
"recognized_terms": recognized_terms,
}
def predict(text):
if not isinstance(text, str) or not text.strip():
raise ValueError("Ticket text is required.")
result = score_model(MODEL["category_model"], text)
return {
"category": result["label"],
"confidence": result["confidence"],
"recognized_terms": result["recognized_terms"],
}
How to compare this file
Check the import order and indentation first. Then compare each function name plus every model key with your saved file.
Create the request handler
Amazon API Gateway sends each browser request to AWS Lambda as an event. The handler reads that event and turns the prediction into an HTTP response.
Payload format 2.0 stores the request body under body. It also uses isBase64Encoded to identify bodies that need decoding.
- Create lambda_function.py inside the existing lambda folder.
- Add the imports and response helper by pasting this code into lambda\lambda_function.py:
import base64
import json
from inference import predict
def api_response(status_code, payload):
return {
"statusCode": status_code,
"headers": {"content-type": "application/json"},
"isBase64Encoded": False,
"body": json.dumps(payload),
}
What does the response helper do?
- The handler imports predict() from the inference module you just built.
- The api_response() helper creates the proxy response shape expected by API Gateway.
- The response body becomes a JSON string before Lambda returns it.
- Save lambda\lambda_function.py.
- Compile the response helper by running:
python -m py_compile lambda\lambda_function.py
What does a quiet return mean?
A quiet return confirms that the imports and response dictionary use valid Python syntax. The request-processing function can now build on this helper.
Handler import failing?
- Confirm that lambda_function.py and inference.py are both inside the lambda folder.
- Check that the import says from inference import predict.
Still seeing a problem? Help me diagnose the handler import.
The main handler decodes the request when needed. It returns a successful prediction or a validation response with status code 400.
- Add lambda_handler() below api_response() in lambda\lambda_function.py by pasting:
def lambda_handler(event, context):
del context
try:
body = event.get("body") or "{}"
if event.get("isBase64Encoded"):
body = base64.b64decode(body).decode("utf-8")
payload = json.loads(body)
result = predict(payload.get("text", ""))
print(json.dumps({"category": result["category"]}))
return api_response(200, result)
except (ValueError, json.JSONDecodeError) as error:
print(json.dumps({"error": str(error)}))
return api_response(400, {"error": str(error)})
How does the handler process a request?
- The handler reads the request body from the event.
- A base64 body is decoded before JSON parsing.
- Valid ticket text reaches predict() and produces a status code 200 response.
- Invalid ticket text or invalid JSON produces a status code 400 response.
- Save lambda\lambda_function.py.
- Compile the completed request handler by running:
python -m py_compile lambda\lambda_function.py
What does this prove?
A quiet return confirms that Python can parse the complete Lambda handler. The local backend is ready to receive API Gateway events after deployment.
Request handler not compiling?
- Check that the try and except blocks use matching indentation.
- Confirm that json.JSONDecodeError has the same capitalization as the reference.
- Check every closing parenthesis in the two print() calls.
Need help finding the mismatch? Help me debug lambda_function.py.
✔️ Awesome, I've got everything!
Your inference module and Lambda handler are now connected. Save both files before building the frontend.
ⓧ I'd like to double check the full code
import base64
import json
from inference import predict
def api_response(status_code, payload):
return {
"statusCode": status_code,
"headers": {"content-type": "application/json"},
"isBase64Encoded": False,
"body": json.dumps(payload),
}
def lambda_handler(event, context):
del context
try:
body = event.get("body") or "{}"
if event.get("isBase64Encoded"):
body = base64.b64decode(body).decode("utf-8")
payload = json.loads(body)
result = predict(payload.get("text", ""))
print(json.dumps({"category": result["category"]}))
return api_response(200, result)
except (ValueError, json.JSONDecodeError) as error:
print(json.dumps({"error": str(error)}))
return api_response(400, {"error": str(error)})
How to compare this file
Compare the imports and response keys first. Then check the indentation through the complete try and except flow.
Build and check the ticket interface
The browser interface turns the inference API into something people can demonstrate. Its HTML form collects a ticket while its JavaScript sends the request and renders the prediction.
The API does not have a deployed URL yet. The local page detects the placeholder and gives you a clear deployment message.
- Create index.html inside the existing frontend folder.
- Build the ticket form by pasting this document structure into frontend\index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Support Ticket Router</title>
</head>
<body>
<main>
<h1>Support Ticket Router</h1>
<p>Route an IT help-desk request to the right support queue.</p>
<form id="ticket-form">
<label for="ticket">Ticket text</label>
<textarea id="ticket" required placeholder="I am locked out after changing my password"></textarea>
<button id="submit-button" type="submit">Classify ticket</button>
</form>
<section id="result" aria-live="polite"></section>
</main>
</body>
</html>
What does this structure provide?
- The form gives the ticket text a labeled textarea.
- The submit button starts classification once the script is connected.
- The result section reserves an accessible place for loading messages and predictions.
- Save frontend\index.html.
- Open the plain ticket form in your default browser by running:
Start-Process -FilePath "frontend\index.html"
What should you see?
Your browser shows the Support Ticket Router heading plus a ticket field and a classification button. The page is plain because styling comes next.
Page not opening?
- Confirm that PowerShell is still inside the support-ticket-router folder.
- Check that the file path is frontend\index.html.
Still unable to open the form? Help me check the local HTML path.
The first style group centers the interface and turns the main content into a card. It also establishes the page colors and shared font.
- Insert this style block below the <title> line in frontend\index.html:
<style>
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #172033;
background: #eef2ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
main {
width: min(680px, 100%);
background: white;
border-radius: 20px;
padding: 32px;
box-shadow: 0 24px 70px rgba(30, 41, 59, 0.14);
}
</style>
What does this styling change?
The body becomes a full-height grid that centers the interface. The main element gains a white background plus spacing and a soft shadow.
- Save frontend\index.html.
- Refresh the browser page.
You should see the form centered inside a white card on a pale background.
Card still unstyled?
- Confirm that the <style> block remains inside <head>.
- Check that the closing </style> tag appears before </head>.
Need help finding the style issue? Help me inspect the card styling.
The form controls need their own visual hierarchy. The next styles make the textarea easier to use and turn the submit control into the primary action.
- Insert these selectors below the main selector and before </style>:
h1 { margin: 0 0 8px; }
p { color: #526078; }
label { display: block; margin: 24px 0 8px; font-weight: 700; }
textarea {
width: 100%;
min-height: 150px;
resize: vertical;
border: 1px solid #c7d2fe;
border-radius: 12px;
padding: 14px;
font: inherit;
}
button {
width: 100%;
margin-top: 16px;
border: 0;
border-radius: 12px;
padding: 14px;
color: white;
background: #4f46e5;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:disabled { opacity: 0.6; cursor: wait; }
How do the controls improve?
The textarea fills the card width and keeps enough height for a detailed ticket. The button gains a clear primary color plus a disabled state for requests in progress.
- Save frontend\index.html.
- Refresh the browser page.
You should see a rounded ticket field above a full-width purple button.
Controls still look plain?
- Check that the new selectors sit before the existing </style> tag.
- Confirm that the document still contains lowercase textarea and button elements.
Need help matching the selectors? Help me fix the form control styles.
The result card stays hidden until the form has something to report. Its value and error styles make successful categories and failed requests easy to distinguish.
- Insert the result selectors below button:disabled and before </style>:
#result {
display: none;
margin-top: 22px;
padding: 18px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.value { font-size: 1.35rem; font-weight: 800; color: #3730a3; }
.error { color: #b91c1c; }
What does the result styling do?
The hidden result panel avoids an empty box before submission. Category values receive stronger text while validation messages use the error color.
- Save frontend\index.html.
- Refresh the browser page.
The visible form should remain unchanged because the result panel starts hidden.
Seeing an empty result box?
- Confirm that the selector starts with #result.
- Check that the selector contains display: none.
Still seeing the empty panel? Help me inspect the result selector.
The first script section stores the future endpoint plus readable category labels. It also catches the local placeholder before any network request begins.
- Insert this script below </main> and before </body>:
<script>
const API_URL = "your-api-url-here";
const labels = {
account_access: "Account access",
billing: "Billing",
technical_issue: "Technical issue"
};
const form = document.querySelector("#ticket-form");
const button = document.querySelector("#submit-button");
const result = document.querySelector("#result");
form.addEventListener("submit", async (event) => {
event.preventDefault();
result.style.display = "block";
if (API_URL === "your-api-url-here") {
result.innerHTML = '<p class="error">Deploy the API and replace your-api-url-here first.</p>';
return;
}
});
</script>
What happens before deployment?
The script connects to the form plus its button and result panel. Submitting while API_URL contains the placeholder reveals the deployment message and stops the request.
- Save frontend\index.html.
- Refresh the browser page.
- Enter I am locked out after changing my password in the ticket field.
- Click Classify ticket.
You should see the result card report Deploy the API and replace your-api-url-here first..
Deployment message not appearing?
- Confirm that the script sits below the form elements.
- Check that API_URL still equals your-api-url-here.
- Compare #ticket-form and #result with the matching element IDs.
Need help tracing the event? Help me debug the local deployment message.
The remaining request logic provides the loading state and future fetch() call. It also renders the category label plus the confidence and recognized terms returned by Lambda.
- Find the closing brace immediately below return; in the form listener.
- Insert this request logic below that brace and before the final }); line:
button.disabled = true;
button.textContent = "Classifying...";
result.textContent = "Sending ticket to AWS...";
try {
const response = await fetch(API_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: document.querySelector("#ticket").value })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Classification failed");
result.innerHTML = `
<div class="value">${labels[data.category] || data.category}</div>
<p>Confidence: ${(data.confidence * 100).toFixed(1)}%</p>
<p>Recognized terms: ${data.recognized_terms.join(", ")}</p>
`;
} catch (error) {
result.innerHTML = `<p class="error">Request failed: ${error.message}</p>`;
} finally {
button.disabled = false;
button.textContent = "Classify ticket";
}
How will a deployed request behave?
- The button becomes disabled while the ticket travels to the API.
- A successful response displays the readable category plus its confidence and recognized terms.
- A failed request displays the error inside the same result card.
- The final block restores the button after either outcome.
- Save frontend\index.html.
✔️ Awesome, I've got everything!
Your browser interface now contains the complete loading and result flow. Keep your-api-url-here in place until the API is deployed.
ⓧ I'd like to double check the full code
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Support Ticket Router</title>
<style>
:root {
color-scheme: light;
font-family: Inter, system-ui, sans-serif;
color: #172033;
background: #eef2ff;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
main {
width: min(680px, 100%);
background: white;
border-radius: 20px;
padding: 32px;
box-shadow: 0 24px 70px rgba(30, 41, 59, 0.14);
}
h1 { margin: 0 0 8px; }
p { color: #526078; }
label { display: block; margin: 24px 0 8px; font-weight: 700; }
textarea {
width: 100%;
min-height: 150px;
resize: vertical;
border: 1px solid #c7d2fe;
border-radius: 12px;
padding: 14px;
font: inherit;
}
button {
width: 100%;
margin-top: 16px;
border: 0;
border-radius: 12px;
padding: 14px;
color: white;
background: #4f46e5;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:disabled { opacity: 0.6; cursor: wait; }
#result {
display: none;
margin-top: 22px;
padding: 18px;
border-radius: 12px;
background: #f8fafc;
border: 1px solid #e2e8f0;
}
.value { font-size: 1.35rem; font-weight: 800; color: #3730a3; }
.error { color: #b91c1c; }
</style>
</head>
<body>
<main>
<h1>Support Ticket Router</h1>
<p>Route an IT help-desk request to the right support queue.</p>
<form id="ticket-form">
<label for="ticket">Ticket text</label>
<textarea id="ticket" required placeholder="I am locked out after changing my password"></textarea>
<button id="submit-button" type="submit">Classify ticket</button>
</form>
<section id="result" aria-live="polite"></section>
</main>
<script>
const API_URL = "your-api-url-here";
const labels = {
account_access: "Account access",
billing: "Billing",
technical_issue: "Technical issue"
};
const form = document.querySelector("#ticket-form");
const button = document.querySelector("#submit-button");
const result = document.querySelector("#result");
form.addEventListener("submit", async (event) => {
event.preventDefault();
result.style.display = "block";
if (API_URL === "your-api-url-here") {
result.innerHTML = '<p class="error">Deploy the API and replace your-api-url-here first.</p>';
return;
}
button.disabled = true;
button.textContent = "Classifying...";
result.textContent = "Sending ticket to AWS...";
try {
const response = await fetch(API_URL, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ text: document.querySelector("#ticket").value })
});
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Classification failed");
result.innerHTML = `
<div class="value">${labels[data.category] || data.category}</div>
<p>Confidence: ${(data.confidence * 100).toFixed(1)}%</p>
<p>Recognized terms: ${data.recognized_terms.join(", ")}</p>
`;
} catch (error) {
result.innerHTML = `<p class="error">Request failed: ${error.message}</p>`;
} finally {
button.disabled = false;
button.textContent = "Classify ticket";
}
});
</script>
</body>
</html>
How to compare this file
Compare the page in three sections. Check the styles first followed by the form and the complete script.
Before you run the final syntax check, predict whether Python will print a success message or return quietly.
- Compile both Python modules together by running:
python -m py_compile lambda\inference.py lambda\lambda_function.py
What should you see?
The command returns quietly when both modules compile successfully. Any syntax problem prints the affected filename and line number.
Before you check the browser again, predict whether the local page can contact an API while the placeholder remains.
- Open the completed interface in your default browser by running:
Start-Process -FilePath "frontend\index.html"
What does this command do?
PowerShell opens the local HTML document with the application associated with HTML files. On a typical Windows workstation, that application is your default browser.
- Enter I am locked out after changing my password in the ticket field.
- Click Classify ticket.
You should see the styled result card report Deploy the API and replace your-api-url-here first. This confirms that the complete form and its local guard are working.
Final check not matching?
- Refresh the browser after saving frontend\index.html.
- Confirm that API_URL still contains your-api-url-here.
- Rerun the compilation command after saving both Python files.
Still seeing a different result? Help me verify the complete local inference app.
That is the local application complete. Your model can now score tickets through a cloud-ready handler while the browser clearly identifies the missing deployment URL.
Next, you will deploy the API plus the private frontend infrastructure and connect this interface to its live endpoint.
Deploy the Initial Cloud Stack
Your local classifier now has a browser interface plus a Python handler. This step puts that handler behind Amazon API Gateway and AWS Lambda so the browser can reach it over HTTPS.
You will define the complete environment with AWS SAM and deploy it through AWS CloudFormation. The interface will load through Amazon CloudFront while its files remain inside a private Amazon S3 bucket.
In this step, get ready to:
- Define the HTTP API plus Lambda function in template.yaml.
- Add the private frontend bucket plus CloudFront distribution.
- Deploy the stack then test the browser request.
Define the API and Lambda function
An HTTP API gives your browser a public route for classification requests. The first template deliberately contains no browser access policy because the initial deployment must reveal how the browser handles that boundary.
- Create template.yaml inside the open support-ticket-router workspace using the VS Code Explorer sidebar.
- Add the template header plus the TicketApi resource by pasting this code:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless support ticket router
Resources:
TicketApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: Prod
DefinitionBody:
openapi: 3.0.1
info:
title: Support Ticket Router API
version: '1.0'
paths: {}
What does this resource define?
- The SAM transform tells CloudFormation to expand serverless resource definitions during deployment.
- The Prod stage becomes part of the deployed classification URL.
- The inline OpenAPI definition gives SAM a document it can extend with the Lambda route.
- Add the TicketClassifierFunction resource directly below the TicketApi resource:
TicketClassifierFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambda/
Handler: lambda_function.lambda_handler
Runtime: python3.14
Architectures:
- x86_64
MemorySize: 256
Timeout: 10
Events:
Classify:
Type: HttpApi
Properties:
ApiId: !Ref TicketApi
Path: /classify
Method: POST
PayloadFormatVersion: '2.0'
How does the function connect?
- The function packages the existing lambda/ folder for the managed python3.14 runtime.
- The handler points to the existing lambda_handler() function in lambda_function.py.
- The event connects POST requests on /classify to the function using payload format 2.0.
- Save template.yaml.
- Build the API and function resources by running this command in the activated VS Code terminal:
sam build
What does this build check?
AWS SAM reads template.yaml and prepares the files under lambda/ for deployment. A successful completion confirms that the route references a buildable Lambda function.
You should see a successful build confirmation. Your function package now includes the handler plus the trained model artifact.
Build did not complete?
- Confirm the terminal is inside the support-ticket-router folder containing template.yaml.
- Check that lambda/lambda_function.py plus lambda/model.json still appear in the VS Code Explorer.
Need a second pair of eyes? Help me troubleshoot my initial SAM build.
Protect the frontend origin
The frontend needs storage plus an HTTPS delivery layer. An origin access control lets CloudFront read the S3 files without making the bucket public.
- Add the private FrontendBucket resource below TicketClassifierFunction in template.yaml.
FrontendBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
Why block public access?
All four controls prevent the bucket from becoming a public website origin. Visitors receive the interface through CloudFront while the underlying S3 bucket stays private.
- Add the FrontendOriginAccessControl resource below FrontendBucket.
FrontendOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-frontend-oac'
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
How is origin access secured?
CloudFront signs every request it sends to the S3 origin. S3 can then distinguish the distribution from an unauthenticated visitor.
- Save template.yaml.
- Check the new storage resources by rebuilding the application:
sam build
What did this build prove?
The successful build confirms that SAM can parse the private bucket plus the origin access control. It also rebuilds the Lambda package using the latest template.
You should see another successful build confirmation. The frontend storage layer now passes the local template check.
- Add the first part of FrontendDistribution below FrontendOriginAccessControl.
FrontendDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
HttpVersion: http2
IPV6Enabled: true
Origins:
- Id: FrontendS3Origin
DomainName: !GetAtt FrontendBucket.RegionalDomainName
OriginAccessControlId: !GetAtt FrontendOriginAccessControl.Id
S3OriginConfig:
OriginAccessIdentity: ''
What does the origin section control?
This section connects the distribution to the regional S3 endpoint. The empty legacy origin identity leaves request signing to the origin access control.
- Complete FrontendDistribution by adding this cache behavior directly below the origin section:
DefaultCacheBehavior:
TargetOriginId: FrontendS3Origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
Compress: true
ForwardedValues:
QueryString: false
Cookies:
Forward: none
MinTTL: 0
DefaultTTL: 0
MaxTTL: 0
How will the site be delivered?
- The viewer policy redirects visitors to HTTPS.
- The allowed methods keep the static origin read-only.
- The zero cache values make an updated index.html available without a long cache delay.
- Save template.yaml.
- Check the completed distribution resource by rebuilding the application:
sam build
What did the distribution check prove?
The build confirms that the distribution points to the named S3 origin. It also confirms that the default cache behavior targets the same origin identifier.
You should see the build complete successfully. The HTTPS delivery layer is now represented in your template.
- Add the FrontendBucketPolicy resource below FrontendDistribution.
FrontendBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref FrontendBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontReadOnly
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${FrontendBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${FrontendDistribution}'
What does the bucket policy allow?
The policy grants read access to the CloudFront service. Its source condition narrows that permission to the distribution created by this stack.
- Add the stack outputs at the end of template.yaml.
Outputs:
TicketApiUrl:
Description: POST endpoint for ticket classification
Value: !Sub 'https://${TicketApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/classify'
FrontendBucketName:
Description: Private bucket that stores index.html
Value: !Ref FrontendBucket
CloudFrontUrl:
Description: HTTPS URL for the web interface
Value: !Sub 'https://${FrontendDistribution.DomainName}'
Why expose these outputs?
- The API output gives the browser its classification endpoint.
- The bucket output identifies where you upload index.html.
- The CloudFront output gives you the public HTTPS address for the interface.
- Save template.yaml.
- Run the final local build before deploying the complete template:
sam build
What does the final build confirm?
This build checks the complete initial template plus the Lambda source package. A successful completion means the stack is ready for its first guided deployment.
You should see the build finish successfully. That is the local green light for deployment.
Template stopped building?
- Check that each resource under Resources starts with two spaces.
- Check that Outputs starts at the far left with no leading spaces.
- Compare the distribution origin identifier with FrontendS3Origin in the default cache behavior.
Still blocked? Help me find the YAML structure problem.
✔️ Awesome, I've got everything!
Your initial template defines the API, function, private S3 origin, CloudFront distribution, bucket policy, plus all three outputs. Make sure template.yaml is saved before continuing.
ⓧ I'd like to double check the full code
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless support ticket router
Resources:
TicketApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: Prod
DefinitionBody:
openapi: 3.0.1
info:
title: Support Ticket Router API
version: '1.0'
paths: {}
TicketClassifierFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambda/
Handler: lambda_function.lambda_handler
Runtime: python3.14
Architectures:
- x86_64
MemorySize: 256
Timeout: 10
Events:
Classify:
Type: HttpApi
Properties:
ApiId: !Ref TicketApi
Path: /classify
Method: POST
PayloadFormatVersion: '2.0'
FrontendBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
FrontendOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-frontend-oac'
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
FrontendDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
HttpVersion: http2
IPV6Enabled: true
Origins:
- Id: FrontendS3Origin
DomainName: !GetAtt FrontendBucket.RegionalDomainName
OriginAccessControlId: !GetAtt FrontendOriginAccessControl.Id
S3OriginConfig:
OriginAccessIdentity: ''
DefaultCacheBehavior:
TargetOriginId: FrontendS3Origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
Compress: true
ForwardedValues:
QueryString: false
Cookies:
Forward: none
MinTTL: 0
DefaultTTL: 0
MaxTTL: 0
FrontendBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref FrontendBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontReadOnly
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${FrontendBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${FrontendDistribution}'
Outputs:
TicketApiUrl:
Description: POST endpoint for ticket classification
Value: !Sub 'https://${TicketApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/classify'
FrontendBucketName:
Description: Private bucket that stores index.html
Value: !Ref FrontendBucket
CloudFrontUrl:
Description: HTTPS URL for the web interface
Value: !Sub 'https://${FrontendDistribution.DomainName}'
Deploy and test the browser path
Guided deployment collects the stack settings once. AWS SAM saves those choices so later deployments can use the shorter deployment command.
CloudFront provisioning can take several minutes while AWS creates the distribution. A quiet terminal during that period does not mean the deployment has stalled.
- Start the guided deployment from the support-ticket-router folder by running:
sam deploy --guided
What happens during guided deployment?
AWS SAM asks for deployment settings before handing the template to CloudFormation. It then creates the function, API, private bucket, distribution, policy, plus the supporting deployment artifacts.
- Enter support-ticket-router when the deployment asks for the stack name.
- Choose your preferred AWS Region when prompted.
- Allow AWS SAM to create the IAM roles required by the Lambda function.
- Approve the deployment when AWS SAM presents the proposed changes.
- Save the deployment settings when AWS SAM offers to store them.
When deployment finishes, you should see the stack outputs for TicketApiUrl, FrontendBucketName, plus CloudFrontUrl.
- Record the deployed API endpoint as your TicketApiUrl output.
- Record the private bucket name as your FrontendBucketName output.
- Record the public site address as your CloudFrontUrl output.
Deployment did not finish?
- Confirm the authenticated AWS profile belongs to the account where you want to create the stack.
- Read the first failed resource in the CloudFormation event output because later failures may only reflect that first problem.
- Run the deployment from the same Region you selected during the guided prompts.
Need help reading the failure? Help me diagnose my SAM deployment.
- In frontend/index.html, locate the API_URL line containing your-api-url-here.
- Replace your-api-url-here with your TicketApiUrl output.
- Save frontend/index.html.
- Upload frontend/index.html by running the command below after replacing your-frontend-bucket-name with your FrontendBucketName output:
aws s3 cp frontend/index.html s3://your-frontend-bucket-name/index.html --content-type text/html
What does this upload do?
The command copies the updated HTML file into the private frontend bucket. The explicit content type tells browsers to interpret the object as an HTML document when CloudFront serves it.
You should see confirmation that frontend/index.html was uploaded to the bucket.
Frontend upload failed?
- Confirm you replaced the bucket placeholder with the exact FrontendBucketName output.
- Confirm the terminal still points to the support-ticket-router folder containing the frontend directory.
- Confirm your active AWS profile targets the account where the stack was deployed.
Still unable to upload? Help me troubleshoot the S3 copy command.
- Paste your CloudFrontUrl output into your browser address bar.
- Press Enter to load the HTTPS site.
You should see the styled Support Ticket Router form. The private S3 object is now reaching your browser through CloudFront.
Before you submit the first ticket, do you think the browser can read the API response from this initial deployment?
- Enter I am locked out after changing my password in the ticket field.
- Click Classify ticket.
You will see a message beginning with Request failed. This planned failure proves that the browser reached the point where it attempted the cross-origin API request.
Why did the browser block the result?
The page and API use different origins. CORS tells the browser whether the page is permitted to read a response from that API.
The initial TicketApi resource has no CORS configuration. The browser therefore protects the response from the CloudFront page.
Your full serverless stack is deployed, your private frontend loads over HTTPS, plus the browser has exposed the intended CORS barrier. Next, you will update the API policy and turn this failed request into a working classification.
Enable CORS and Publish the App
Your Amazon CloudFront site can reach the Amazon API Gateway endpoint from the previous step. The browser blocks the response because the API does not grant cross-origin access.
Enabling CORS on TicketApi lets the page read the AWS Lambda response. AWS SAM then updates the existing AWS CloudFormation stack without disturbing the private frontend.
In this step, get ready to:
- Enable CORS for the HTTP API in template.yaml.
- Redeploy the existing stack with its saved configuration.
- Verify successful classifications through CloudFront.
Enable CORS in the HTTP API
An HTTP API can tell a browser which cross-origin requests are permitted. AWS SAM adds those rules when CorsConfiguration is enabled beside the inline API definition.
- In template.yaml from earlier, find the TicketApi resource.
- Under Properties, locate this line:
StageName: Prod
What Does This Line Identify?
The StageName value publishes the API under its Prod stage. The frontend URL already points to that stage.
- Add CorsConfiguration: true directly below the stage name so the block now matches:
StageName: Prod
CorsConfiguration: true
DefinitionBody:
What Does This Change Do?
The CorsConfiguration: true setting allows all origins for this HTTP API. The inline DefinitionBody gives AWS SAM the API definition it needs to update.
The route stays at /classify. The Lambda integration also stays unchanged.
- Save template.yaml.
✔️ Awesome, I've got everything!
Your API definition now enables CORS while keeping the existing stage and route.
ⓧ I'd like to double check the full code
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Serverless support ticket router
Resources:
TicketApi:
Type: AWS::Serverless::HttpApi
Properties:
StageName: Prod
CorsConfiguration: true
DefinitionBody:
openapi: 3.0.1
info:
title: Support Ticket Router API
version: '1.0'
paths: {}
TicketClassifierFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambda/
Handler: lambda_function.lambda_handler
Runtime: python3.14
Architectures:
- x86_64
MemorySize: 256
Timeout: 10
Events:
Classify:
Type: HttpApi
Properties:
ApiId: !Ref TicketApi
Path: /classify
Method: POST
PayloadFormatVersion: '2.0'
FrontendBucket:
Type: AWS::S3::Bucket
Properties:
PublicAccessBlockConfiguration:
BlockPublicAcls: true
BlockPublicPolicy: true
IgnorePublicAcls: true
RestrictPublicBuckets: true
FrontendOriginAccessControl:
Type: AWS::CloudFront::OriginAccessControl
Properties:
OriginAccessControlConfig:
Name: !Sub '${AWS::StackName}-frontend-oac'
OriginAccessControlOriginType: s3
SigningBehavior: always
SigningProtocol: sigv4
FrontendDistribution:
Type: AWS::CloudFront::Distribution
Properties:
DistributionConfig:
Enabled: true
DefaultRootObject: index.html
HttpVersion: http2
IPV6Enabled: true
Origins:
- Id: FrontendS3Origin
DomainName: !GetAtt FrontendBucket.RegionalDomainName
OriginAccessControlId: !GetAtt FrontendOriginAccessControl.Id
S3OriginConfig:
OriginAccessIdentity: ''
DefaultCacheBehavior:
TargetOriginId: FrontendS3Origin
ViewerProtocolPolicy: redirect-to-https
AllowedMethods:
- GET
- HEAD
CachedMethods:
- GET
- HEAD
Compress: true
ForwardedValues:
QueryString: false
Cookies:
Forward: none
MinTTL: 0
DefaultTTL: 0
MaxTTL: 0
FrontendBucketPolicy:
Type: AWS::S3::BucketPolicy
Properties:
Bucket: !Ref FrontendBucket
PolicyDocument:
Version: '2012-10-17'
Statement:
- Sid: AllowCloudFrontReadOnly
Effect: Allow
Principal:
Service: cloudfront.amazonaws.com
Action: s3:GetObject
Resource: !Sub '${FrontendBucket.Arn}/*'
Condition:
StringEquals:
AWS:SourceArn: !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${FrontendDistribution}'
Outputs:
TicketApiUrl:
Description: POST endpoint for ticket classification
Value: !Sub 'https://${TicketApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/Prod/classify'
FrontendBucketName:
Description: Private bucket that stores index.html
Value: !Ref FrontendBucket
CloudFrontUrl:
Description: HTTPS URL for the web interface
Value: !Sub 'https://${FrontendDistribution.DomainName}'
What Should Match?
The TicketApi resource contains CorsConfiguration: true above its inline DefinitionBody. The function and private frontend resources remain unchanged.
Rebuild and redeploy the stack
AWS SAM rebuilds the deployment package before AWS CloudFormation applies the template change. The saved deployment configuration means you can update the existing stack without repeating the guided setup.
The stack update can take several minutes while AWS CloudFormation applies it. A quiet pause during deployment is expected.
Before you deploy, do you think AWS SAM replaces every resource or updates the API configuration in place?
- Rebuild the application and deploy the saved stack configuration by running:
sam build
sam deploy
What Do These Commands Do?
The first command prepares the application using the updated template. The second command sends the change to the existing support-ticket-router stack.
CloudFormation updates the API configuration in place. The deployed Lambda function and private frontend remain available.
- Wait for the terminal to confirm that the stack update completed successfully.
You should see the deployment finish successfully. The existing API URL and CloudFront URL remain available.
Deployment Did Not Complete?
- Confirm that the terminal is still inside the support-ticket-router workspace.
- Confirm that template.yaml is saved with CorsConfiguration: true under TicketApi.
- Confirm that your authenticated AWS profile still targets the account that contains the stack.
Still stuck? Help me troubleshoot the failed AWS SAM stack update.
Verify the published app and private origin
The refreshed frontend now has permission to read the API response. Testing all three categories confirms that the complete browser-to-Lambda path works.
- Switch back to the CloudFront site from the previous step.
- Refresh the page.
- Enter I am locked out after changing my password in the ticket text area.
Before you select Classify ticket, which category do you expect the model to return?
- Select Classify ticket.
You should see Account access with a confidence percentage and recognized terms. The previous request failure is gone.
- Enter Can you refund the duplicate subscription charge in the ticket text area.
- Select Classify ticket.
You should see Billing with a confidence percentage and recognized terms.
- Enter The API upload endpoint keeps timing out in the ticket text area.
- Select Classify ticket.
You should see Technical issue with a confidence percentage and recognized terms.
What Changed?
The successful results prove that the browser can now read responses from the API. CORS completes the path from the CloudFront page to the Lambda classifier.
- Enter three space characters in the ticket text area.
- Select Classify ticket.
You should see Request failed: Ticket text is required. This confirms that the API returns its validation response for text that becomes empty after whitespace is removed.
The Amazon S3 bucket remains a private origin for CloudFront. A direct request to index.html should still be denied.
- Open the AWS Management Console in your browser.
- Go to AWS CloudFormation.
- Select the support-ticket-router stack.
- Follow the physical ID for the FrontendBucket resource to its S3 bucket.
- Select index.html in the bucket.
Before you use the direct object URL, do you expect S3 or CloudFront to serve the page?
- Select the direct object URL shown in the object details.
The direct request is denied because public S3 access remains blocked. Your CloudFront URL is the public path to the frontend.
You closed the browser barrier and kept the origin private. Your serverless ticket router now works from its public HTTPS interface.
Secret mission
Add an Urgency Classifier
Your router knows which support queue should receive a ticket. Extend it with a second classifier that identifies Low, Medium, or High urgency so the support team also knows what to handle first.
Clean Up Your Resources
Clean Up Your Resources
Your local files cost nothing to keep. Choose whether to keep the usage-based cloud stack running, make the site unavailable for now, or delete the project completely.
Cost warning
The deployed stack can continue to incur usage charges during a short personal test. Choose Pause or Delete if you no longer need the live router.
Resources you used:
- Your AWS CloudFormation stack named support-ticket-router.
- The Amazon API Gateway HTTP API managed by the stack.
- The AWS Lambda function plus its IAM execution role managed by the stack.
- The private Amazon S3 frontend bucket. It contains the updated index.html object.
- The Amazon CloudFront origin access control plus distribution managed by the stack.
- The bucket policy plus AWS SAM deployment artifacts associated with the stack.
- Your local support-ticket-router workspace. Its .venv contains scikit-learn 1.9.1.
Keep everything running
No action needed. Choose this if you want the live router to remain available for demonstrations or further experiments.
- Keep the support-ticket-router stack deployed.
- Leave the local support-ticket-router workspace in its current location.
- Monitor your AWS billing dashboard to check for usage charges.
Pause - I'll come back to this later
There is no single pause switch for this serverless stack. Removing the frontend object makes the site unavailable while preserving the deployed stack.
- Switch back to the activated terminal in Visual Studio Code from earlier.
- Replace your-frontend-bucket-name in the command below with the frontend bucket name from your stack output.
- Remove the published frontend object by running this command:
aws s3 rm s3://your-frontend-bucket-name --recursive
What does this command do?
This command removes every object from the private frontend bucket. The CloudFront distribution can no longer retrieve the interface from its origin.
- Leave the support-ticket-router stack deployed.
- Keep the local support-ticket-router workspace for your return.
- Avoid sending requests to the API while the project is paused.
The remaining cloud resources stay deployed. Usage-based charges remain possible.
Delete - I don't want to use this again
Deleting the stack is permanent, so this option can feel drastic. Your local source remains safe until the final folder cleanup.
- Switch back to the activated terminal in Visual Studio Code from earlier.
- Replace your-frontend-bucket-name in the command below with the frontend bucket name from your stack output.
- Empty the frontend bucket by running this command:
aws s3 rm s3://your-frontend-bucket-name --recursive
Why empty the bucket first?
AWS CloudFormation can delete the frontend bucket only after every object has been removed. This command clears that final dependency.
AWS SAM asks for confirmation before it removes the stack. Approving each prompt starts the permanent cloud cleanup.
- Prepare to approve each confirmation prompt after the command starts.
- Delete the CloudFormation stack plus its packaged deployment artifacts by running this command:
sam delete --stack-name support-ticket-router
What does this command do?
AWS SAM deletes the CloudFormation stack plus its packaged deployment artifacts. CloudFormation removes every cloud resource listed above.
CloudFront cleanup can take several minutes while the distribution deletion propagates. A quiet wait during this stage is expected.
- Wait for the terminal to confirm that the stack deletion is complete.
Stack deletion stuck?
- Confirm that the frontend bucket is empty.
- Confirm that your authenticated AWS profile targets the account that owns support-ticket-router.
- Confirm that your AWS CLI configuration uses the Region where you deployed the stack.
- Help me diagnose the failed stack deletion.
- Close Visual Studio Code.
- Use Windows File Explorer to locate the support-ticket-router workspace in the location where you created it.
- Delete the support-ticket-router workspace folder.
- Empty the Windows Recycle Bin to remove the local files from your computer.
The cleanup is complete. Your deployed router no longer serves requests.
Nice Work!
Nice Work!
You did it! Your deployed support ticket router now classifies support requests through an HTTPS interface. It also reports urgency through the extension you completed.
You've learned how to:
- Trained a three-class ticket router with scikit-learn. You exported its learned parameters as a portable JSON artifact.
- Recreated Multinomial Naive Bayes inference with the Python standard library. This lets AWS Lambda classify tickets without shipping scikit-learn.
- Deployed a serverless HTTPS application with AWS SAM. You fixed the browser's CORS failure in Amazon API Gateway. You served the interface through Amazon CloudFront from a private Amazon S3 origin.
- Completed the optional Secret Mission by training a second classifier for Low, Medium, or High urgency. The live result card now shows category and urgency with separate confidence scores.
Ready to quiz yourself?