The lab has its own AWS account. Running your work through it means the cost lands on the right grant instead of your personal card, and it gives you machines far larger than your laptop when a model needs them.
This guide gets you three things: credentials that let you talk to the lab’s AWS resources, Claude Code billed through Bedrock to the lab account, and SageMaker training jobs that run on cloud hardware with data in S3.
Set aside about 30 minutes.
Install the AWS CLI
The AWS command line tool is how you check credentials, move data in and out of S3, and inspect training jobs without opening a browser.
Windows
winget install Amazon.AWSCLIThen close the terminal and open a new one — the installer edits your PATH, and an
already-open window keeps the old one.
If winget is unavailable, download and run
the MSI installer instead.
macOS
brew install awscliIf you do not have Homebrew, Lab computing setup installs it. Failing that, use Amazon’s installer:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /Linux
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
unzip awscliv2.zip
sudo ./aws/installDo not use apt install awscli — Ubuntu ships version 1, which is a different and much
older tool.
Check it worked:
aws --version
You should get something like aws-cli/2.x.x Python/3.x.x. The leading 2 matters.
Configure your credentials
Adam will send you a CSV file, named something like yourname_accessKeys.csv. Open it and you
will find two values: an Access key ID starting with AKIA, and a Secret access key,
which is a long random string.
Run:
aws configure
It asks four questions. Paste the two values from the CSV, then use these answers for the rest:
AWS Access Key ID [None]: AKIAIOSFODNN7EXAMPLE
AWS Secret Access Key [None]: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
Default region name [None]: us-east-1
Default output format [None]: json
The lab runs in us-east-1. Everything in this guide assumes it.
This writes your keys to ~/.aws/credentials and the region to ~/.aws/config. Every AWS tool
you use from now on — the CLI, boto3, SageMaker, Claude Code — reads them from there
automatically. You will not have to type them again.
Check that it worked
aws sts get-caller-identity
{
"UserId": "AIDAI...",
"Account": "YOUR_ACCOUNT_ID",
"Arn": "arn:aws:iam::YOUR_ACCOUNT_ID:user/your-username"
}
If that errors, the keys were mistyped or mis-pasted — run aws configure again and take care
not to include a trailing space.
Now check you can reach the lab’s storage:
aws s3 ls s3://YOUR_BUCKET_NAME/
PRE code/
PRE datasets/
PRE models/
PRE notebooks/
PRE outputs/
PRE means “prefix” — S3’s equivalent of a folder. Those five are the layout the lab uses, and
the rest of this guide writes into them.
Bill Claude Code to the lab account
By default Claude Code bills to a personal Claude subscription. Pointed at Amazon Bedrock, it uses the lab’s AWS account instead, which is what you want for lab work.
You already installed Claude Code in Lab computing setup. It now has a wizard for this, so there is nothing to configure by hand:
claude
At the prompt, type:
/setup-bedrock
The wizard finds the AWS credentials you just configured, confirms your region, checks which
Claude models the lab’s account can actually invoke, and writes the result into your Claude Code
settings at ~/.claude/settings.json. Run it again any time to change region or model.
Check it took
Inside Claude Code, run /status. You are looking for three lines:
Version: 2.1.x
API provider: AWS Bedrock
AWS region: us-east-1
API provider: AWS Bedrock is the one that matters — that is the line that says your usage is
being billed to the lab, not to you.
Per-project, rather than everywhere
The wizard configures Bedrock for all your Claude Code usage. If you also use Claude Code on
personal projects and want those to stay on your own subscription, set it per project instead:
create .claude/settings.json inside the project folder with
{
"env": {
"CLAUDE_CODE_USE_BEDROCK": "1",
"AWS_REGION": "us-east-1"
}
}
Claude Code merges settings from your home directory and the project, with the project winning. Commit this file — it is not secret, it holds no credentials, and it means everyone working on the repository bills the same way.
The full reference for all of this is Anthropic’s Claude Code on Amazon Bedrock page.
Set up a project for SageMaker
SageMaker work is an ordinary Python project. Start it the way Python projects, the lab way describes:
uv init sagemaker-experiments
cd sagemaker-experiments
uv add boto3 sagemaker pandas numpy scikit-learn matplotlib
That gives you:
boto3— the AWS SDK for Python, the general-purpose way to talk to any AWS servicesagemaker— the SageMaker SDK, a friendlier layer for launching training jobspandas,numpy,scikit-learn,matplotlib— the usual analysis stack
Check the SageMaker SDK imports:
uv run python -c "import sagemaker; print(sagemaker.__version__)"
The configuration module
Every script in this guide needs the same three facts: which bucket, which IAM role, which
region. Put them in one file, sagemaker_config.py, and import it everywhere:
"""Shared SageMaker configuration for Gormley Lab projects."""
import boto3
import sagemaker
# The lab's S3 bucket. Ask Adam for the real name.
BUCKET_NAME = "YOUR_BUCKET_NAME"
# The role SageMaker assumes when it runs your job. Ask Adam for the account ID.
SAGEMAKER_ROLE = "arn:aws:iam::YOUR_ACCOUNT_ID:role/GormleyLabSageMakerExecutionRole"
REGION = "us-east-1"
def get_sagemaker_session():
"""A SageMaker session bound to the lab's region."""
return sagemaker.Session(boto_session=boto3.Session(region_name=REGION))
# Where things live in the bucket.
PATHS = {
"datasets": f"s3://{BUCKET_NAME}/datasets/",
"models": f"s3://{BUCKET_NAME}/models/",
"code": f"s3://{BUCKET_NAME}/code/",
"outputs": f"s3://{BUCKET_NAME}/outputs/",
}
# Instance types, cheapest first. See "Keeping costs down" below.
INSTANCE_TYPES = {
"cpu_small": "ml.m5.large", # debugging, small data
"cpu_medium": "ml.m5.xlarge", # medium data, no GPU needed
"gpu_budget": "ml.g4dn.xlarge", # T4 — small networks
"gpu_standard": "ml.g5.xlarge", # A10G — normal deep learning
"gpu_large": "ml.g5.12xlarge", # 4x A10G — large models
}
The IAM role is worth a sentence. Your own credentials let you submit a job. The role is what the training machine itself uses once it starts — that is how it reads your data out of S3 and writes the model back. Adam creates it; you only ever refer to it by that ARN.
How a SageMaker training job works
This is the part that confuses people first, so it is worth being explicit. Every job is built from two files.
The training script — say train.py — holds your actual machine learning code: load data,
define a model, fit it, evaluate, save. This file runs on a remote machine. You never execute
it yourself.
The launcher — say run_training.py — is the file you run locally. It does not train
anything. It describes a job to SageMaker: which script to run, on what hardware, with which
hyperparameters, and where to put the result.
run_training.py (local)
│
│ tells SageMaker:
│ entry_point='train.py' ← which script to run
│ instance_type ← what hardware to use
│ hyperparameters ← become CLI args to train.py
│ output_path ← where to save the model in S3
▼
train.py (remote, on a SageMaker instance)
│
│ receives hyperparameters as command-line arguments
│ trains the model
│ saves the model where SageMaker tells it to
▼
Model artifact uploaded to S3
Two mechanisms connect them, and both look strange until you have seen them once:
- Hyperparameters arrive as command-line arguments. A launcher that sets
hyperparameters={'n-estimators': 100}causes SageMaker to runpython train.py --n-estimators 100on the remote machine. That is why training scripts useargparse. - Paths arrive as environment variables. SageMaker sets
SM_MODEL_DIRto the directory whose contents get uploaded to S3 when the job finishes, andSM_CHANNEL_TRAINto wherever it put your input data. The training script reads them withos.environ.
The upshot is that train.py knows nothing about S3, or the cloud, or SageMaker. It reads a
directory, trains, and writes a directory. You can run it on your laptop unchanged.
Where S3 fits in
S3 is the shared ground between your machine and the remote one. It has two jobs.
Input. Your data has to be somewhere the remote machine can reach, so it lives in S3. You
point .fit() at an S3 path, and SageMaker copies that data onto the instance before your
script starts, leaving the local path in SM_CHANNEL_TRAIN.
Output. When your script writes a model into SM_MODEL_DIR, SageMaker uploads everything in
that directory to the output_path you gave it. That is how the trained model gets back
somewhere you can reach it.
Your data (local)
│
│ aws s3 cp data.csv s3://YOUR_BUCKET_NAME/datasets/
▼
S3 bucket (input) ← data lives here before training
│
│ SageMaker copies it onto the remote instance
▼
run_training.py (local)
│
│ .fit({'train': 's3://...'}) ← where to get input data
│ output_path='s3://...' ← where to put the model
▼
train.py (remote)
│
│ reads the data from a local path (copied from S3)
│ trains the model, saves it to SM_MODEL_DIR
▼
S3 bucket (output) ← trained model uploaded here automatically
│
│ aws s3 cp s3://...model.tar.gz ./
▼
Your machine (local) ← download it and use it
Your first training job
A random forest on the iris dataset. Small, fast, and cheap — the point is the machinery, not the model.
The training script
Create train.py:
"""Train a random forest on iris. Runs on the remote SageMaker instance."""
import argparse
import os
import joblib
from sklearn.datasets import load_iris
from sklearn.ensemble import RandomForestClassifier
from sklearn.metrics import accuracy_score, classification_report
from sklearn.model_selection import train_test_split
def train(args):
print("Loading data...")
# A built-in dataset, so nothing needs uploading to S3 for this example.
# With your own data you would read from args.train instead — that is the
# directory SageMaker copies your S3 input into.
iris = load_iris()
X, y = iris.data, iris.target
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=42
)
print(f"Training set: {len(X_train)} Test set: {len(X_test)}")
print(f"Training a random forest with {args.n_estimators} trees...")
model = RandomForestClassifier(
n_estimators=args.n_estimators,
max_depth=args.max_depth,
random_state=42,
)
model.fit(X_train, y_train)
train_accuracy = accuracy_score(y_train, model.predict(X_train))
test_accuracy = accuracy_score(y_test, model.predict(X_test))
print(f"\nTrain accuracy: {train_accuracy:.4f}")
print(f"Test accuracy: {test_accuracy:.4f}\n")
print(classification_report(y_test, model.predict(X_test), target_names=iris.target_names))
# Anything written here is uploaded to S3 when the job finishes.
model_path = os.path.join(args.model_dir, "model.joblib")
joblib.dump(model, model_path)
print(f"Model saved to {model_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
# Hyperparameters — SageMaker passes these in from the launcher.
parser.add_argument("--n-estimators", type=int, default=100)
parser.add_argument("--max-depth", type=int, default=5)
# Paths — SageMaker sets these environment variables on the instance.
parser.add_argument("--model-dir", type=str, default=os.environ.get("SM_MODEL_DIR", "./model"))
parser.add_argument("--train", type=str, default=os.environ.get("SM_CHANNEL_TRAIN", "./data"))
args = parser.parse_args()
os.makedirs(args.model_dir, exist_ok=True)
train(args)
Note the defaults on the last two arguments. Because they fall back to local directories when
the environment variables are absent, uv run python train.py works on your laptop too. Test
there first — it is free.
The launcher
Create run_training.py:
"""Submit the iris training job to SageMaker. Run this locally."""
from datetime import datetime
from sagemaker.sklearn import SKLearn
import sagemaker_config as config
def main():
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
print(f"Submitting job: sklearn-iris-{timestamp}")
print(f"Instance type: {config.INSTANCE_TYPES['cpu_small']}")
estimator = SKLearn(
entry_point="train.py", # the script that runs remotely
role=config.SAGEMAKER_ROLE,
instance_type=config.INSTANCE_TYPES["cpu_small"],
instance_count=1,
framework_version="1.2-1", # scikit-learn container
py_version="py3",
sagemaker_session=config.get_sagemaker_session(),
hyperparameters={ # become --n-estimators 100
"n-estimators": 100,
"max-depth": 5,
},
output_path=config.PATHS["models"], # where the model lands
code_location=config.PATHS["code"],
base_job_name="sklearn-iris",
use_spot_instances=True, # much cheaper, see below
max_wait=3600, # stop waiting after 1 hour
max_run=1800, # kill the job at 30 minutes
)
print("\nStarting. This takes a few minutes — most of it is machine startup.")
# No argument, because train.py generates its own data. With real data:
# estimator.fit({"train": "s3://YOUR_BUCKET_NAME/datasets/your_data.csv"}, wait=True)
estimator.fit(wait=True)
print(f"\nDone. Model artifact: {estimator.model_data}")
print(f"Job name: {estimator.latest_training_job.name}")
if __name__ == "__main__":
main()
Run it
From your project folder:
uv run python run_training.py
The training logs stream back to your terminal as the job runs:
Submitting job: sklearn-iris-2026-08-23-14-30-45
Instance type: ml.m5.large
Starting. This takes a few minutes — most of it is machine startup.
2026-08-23 14:31:12 Starting - Starting the training job...
2026-08-23 14:31:45 Starting - Preparing the instances for training...
2026-08-23 14:32:30 Downloading - Downloading input data...
2026-08-23 14:32:45 Training - Training image download completed. Training in progress...
Loading data...
Training set: 120 Test set: 30
Training a random forest with 100 trees...
Train accuracy: 1.0000
Test accuracy: 0.9667
2026-08-23 14:33:15 Uploading - Uploading generated training model
2026-08-23 14:33:30 Completed - Training job completed
Done. Model artifact: s3://YOUR_BUCKET_NAME/models/sklearn-iris-.../output/model.tar.gz
Most of that elapsed time is provisioning a machine, not training. That overhead is fixed, which is why very short jobs are better run on your laptop.
A PyTorch job on a GPU
Same two-file pattern, different framework, and a machine with a GPU attached.
The training script
Create train_pytorch.py:
"""Train a small feedforward network. Runs on the remote SageMaker instance."""
import argparse
import os
import torch
import torch.nn as nn
import torch.optim as optim
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import StandardScaler
from torch.utils.data import DataLoader, TensorDataset
class SimpleNN(nn.Module):
def __init__(self, input_dim, hidden_dim, output_dim):
super().__init__()
self.network = nn.Sequential(
nn.Linear(input_dim, hidden_dim),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(hidden_dim, hidden_dim),
nn.ReLU(),
nn.Dropout(0.2),
nn.Linear(hidden_dim, output_dim),
)
def forward(self, x):
return self.network(x)
def run_epoch(model, loader, criterion, device, optimizer=None):
"""One pass over the data. Trains if given an optimizer, evaluates if not."""
training = optimizer is not None
model.train() if training else model.eval()
total_loss, correct, total = 0.0, 0, 0
with torch.set_grad_enabled(training):
for data, target in loader:
data, target = data.to(device), target.to(device)
output = model(data)
loss = criterion(output, target)
if training:
optimizer.zero_grad()
loss.backward()
optimizer.step()
total_loss += loss.item()
correct += output.argmax(1).eq(target).sum().item()
total += target.size(0)
return total_loss / len(loader), 100.0 * correct / total
def main(args):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
# Synthetic data, so nothing needs uploading for this example.
print("Generating synthetic dataset...")
X, y = make_classification(
n_samples=1000, n_features=20, n_informative=15,
n_redundant=5, n_classes=3, random_state=42,
)
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
scaler = StandardScaler()
X_train = scaler.fit_transform(X_train)
X_test = scaler.transform(X_test)
train_loader = DataLoader(
TensorDataset(torch.FloatTensor(X_train), torch.LongTensor(y_train)),
batch_size=args.batch_size, shuffle=True,
)
test_loader = DataLoader(
TensorDataset(torch.FloatTensor(X_test), torch.LongTensor(y_test)),
batch_size=args.batch_size,
)
model = SimpleNN(input_dim=20, hidden_dim=args.hidden_dim, output_dim=3).to(device)
criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=args.learning_rate)
print(f"\nTraining for {args.epochs} epochs...")
for epoch in range(args.epochs):
train_loss, train_acc = run_epoch(model, train_loader, criterion, device, optimizer)
test_loss, test_acc = run_epoch(model, test_loader, criterion, device)
print(
f"Epoch {epoch + 1}/{args.epochs}: "
f"train loss {train_loss:.4f} acc {train_acc:.2f}% | "
f"test loss {test_loss:.4f} acc {test_acc:.2f}%"
)
# Save the scaler alongside the weights — without it the model cannot be used
# on new data, because it expects inputs scaled exactly this way.
model_path = os.path.join(args.model_dir, "model.pth")
torch.save(
{
"model_state_dict": model.state_dict(),
"scaler_mean": scaler.mean_,
"scaler_scale": scaler.scale_,
},
model_path,
)
print(f"\nModel saved to {model_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--batch-size", type=int, default=32)
parser.add_argument("--epochs", type=int, default=10)
parser.add_argument("--learning-rate", type=float, default=0.001)
parser.add_argument("--hidden-dim", type=int, default=64)
parser.add_argument("--model-dir", type=str, default=os.environ.get("SM_MODEL_DIR", "./model"))
args = parser.parse_args()
os.makedirs(args.model_dir, exist_ok=True)
main(args)
The launcher
Create run_pytorch_training.py:
"""Submit the PyTorch training job to SageMaker. Run this locally."""
from datetime import datetime
from sagemaker.pytorch import PyTorch
import sagemaker_config as config
def main():
timestamp = datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
instance_type = config.INSTANCE_TYPES["gpu_budget"]
print(f"Submitting job: pytorch-nn-{timestamp}")
print(f"Instance type: {instance_type}")
estimator = PyTorch(
entry_point="train_pytorch.py",
role=config.SAGEMAKER_ROLE,
instance_type=instance_type,
instance_count=1,
framework_version="2.9", # PyTorch container version
py_version="py312",
sagemaker_session=config.get_sagemaker_session(),
hyperparameters={
"batch-size": 32,
"epochs": 20,
"learning-rate": 0.001,
"hidden-dim": 128,
},
output_path=config.PATHS["models"],
code_location=config.PATHS["code"],
base_job_name="pytorch-nn",
use_spot_instances=True,
max_wait=7200,
max_run=3600,
)
estimator.fit(wait=True)
print(f"\nDone. Model artifact: {estimator.model_data}")
if __name__ == "__main__":
main()
Monitoring your jobs
Jobs keep running whether or not your terminal is open — .fit(wait=True) only controls whether
you wait. Closing your laptop does not stop the job, and does not stop the billing.
From the command line:
# Recent jobs
aws sagemaker list-training-jobs --max-results 10
# Everything about one job, including why it failed
aws sagemaker describe-training-job --training-job-name sklearn-iris-2026-08-23-14-30-45
# Stop one
aws sagemaker stop-training-job --training-job-name sklearn-iris-2026-08-23-14-30-45
In the browser, go to the AWS Console and open SageMaker AI → Training → Training jobs. Each job shows status, duration, the instance it used, and full logs.
For a quicker read on recent work, check_jobs.py:
"""Summarise recent SageMaker training jobs."""
import boto3
sagemaker = boto3.client("sagemaker", region_name="us-east-1")
response = sagemaker.list_training_jobs(
MaxResults=20, SortBy="CreationTime", SortOrder="Descending"
)
print("Recent training jobs")
print("-" * 72)
for job in response["TrainingJobSummaries"]:
status = job["TrainingJobStatus"]
if status in ("Completed", "Failed", "Stopped"):
duration = str(job["TrainingEndTime"] - job["CreationTime"]).split(".")[0]
else:
duration = "in progress"
print(f"\n{job['TrainingJobName']}")
print(f" status: {status}")
print(f" created: {job['CreationTime']:%Y-%m-%d %H:%M:%S}")
print(f" duration: {duration}")
if status == "Failed":
print(f" reason: {job.get('FailureReason', 'unknown')}")
uv run python check_jobs.py
Working with S3
From the command line:
# Upload one file
aws s3 cp my_data.csv s3://YOUR_BUCKET_NAME/datasets/my_data.csv
# Upload a folder
aws s3 cp ./local_data s3://YOUR_BUCKET_NAME/datasets/my_project/ --recursive
# Download a trained model
aws s3 cp s3://YOUR_BUCKET_NAME/models/my_model.tar.gz ./
# See what is there
aws s3 ls s3://YOUR_BUCKET_NAME/datasets/
aws s3 sync is worth knowing too — it copies only what has changed, which matters once a
dataset is large:
aws s3 sync ./local_data s3://YOUR_BUCKET_NAME/datasets/my_project/
From Python, s3_utils.py:
"""Small helpers for moving files in and out of the lab bucket."""
from pathlib import Path
import boto3
import sagemaker_config as config
s3 = boto3.client("s3", region_name=config.REGION)
def upload_file(local_path, s3_key):
print(f"Uploading {local_path} -> s3://{config.BUCKET_NAME}/{s3_key}")
s3.upload_file(str(local_path), config.BUCKET_NAME, s3_key)
return f"s3://{config.BUCKET_NAME}/{s3_key}"
def download_file(s3_key, local_path):
print(f"Downloading s3://{config.BUCKET_NAME}/{s3_key} -> {local_path}")
s3.download_file(config.BUCKET_NAME, s3_key, str(local_path))
def upload_directory(local_dir, s3_prefix):
local_dir = Path(local_dir)
for path in local_dir.rglob("*"):
if path.is_file():
upload_file(path, f"{s3_prefix}/{path.relative_to(local_dir)}")
def list_files(s3_prefix):
"""List objects under a prefix, handling buckets with more than 1000 keys."""
keys = []
paginator = s3.get_paginator("list_objects_v2")
for page in paginator.paginate(Bucket=config.BUCKET_NAME, Prefix=s3_prefix):
keys.extend(obj["Key"] for obj in page.get("Contents", []))
print(f"{len(keys)} file(s) under s3://{config.BUCKET_NAME}/{s3_prefix}")
for key in keys:
print(f" - {key}")
return keys
Keeping costs down
SageMaker bills per second of instance time, from when the machine starts to when it stops. A job you forget about is a job you are paying for.
Pick the smallest machine that works
Cost scales steeply with the instance, roughly like this — ml.m5.large as the unit:
| Use case | Instance | Relative cost | When |
|---|---|---|---|
| Debugging | ml.m5.large |
1× | Getting the job to run at all |
| CPU training | ml.m5.xlarge |
~2× | Medium data, no GPU needed |
| Budget GPU | ml.g4dn.xlarge |
~6× | Small networks, T4 |
| Standard GPU | ml.g5.xlarge |
~12× | Normal deep learning, A10G |
| Large GPU | ml.g5.12xlarge |
~60× | Big models, 4× A10G |
Current per-hour rates are on the SageMaker AI pricing page — check there before committing to anything large, since these change.
The habits that actually save money
- Debug on
cpu_smallfirst. Most failures are typos, missing imports, and wrong paths. Finding them on the cheapest machine costs cents; finding them on a GPU costs real money. - Debug on your laptop before that. The training scripts here run locally unchanged.
- Use spot instances.
use_spot_instances=Truewith amax_waittypically saves 70–90%. AWS can interrupt a spot job, but for training runs that is usually an acceptable trade. - Always set
max_run. It is a hard ceiling on how long the job can go. Without it, a training loop that fails to converge bills until someone notices. - Clean up S3. Model artifacts accumulate. Delete the ones from failed experiments.
Track what you ran
Six months from now you will want to know which hyperparameters produced which result. A plain-text log is enough to start with — append a line per job:
"""Append a record of each submitted job to experiments.jsonl."""
import json
from datetime import datetime, timezone
from pathlib import Path
LOG = Path("experiments.jsonl")
def log_experiment(job_name, hyperparameters, notes=""):
record = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"job_name": job_name,
"hyperparameters": hyperparameters,
"notes": notes,
}
with LOG.open("a") as f:
f.write(json.dumps(record) + "\n")
print(f"Logged {job_name}")
One JSON object per line, appended. It never rewrites the file, so a crash mid-write cannot
destroy your history, and it stays readable with cat. Commit it — it is a record of what you
did.
Security and what not to commit
The single rule: credentials never go in the repository. Everything else follows from it.
Your AWS keys live in ~/.aws/credentials, outside any project, which is exactly why
aws configure is the right way to store them. Nothing in your code should ever contain a key.
If you find yourself typing AKIA into a Python file, stop.
Put this in .gitignore at the start of the project, before there is anything to leak:
# Credentials — never commit
.env
*.pem
*.key
*_accessKeys.csv
credentials.csv
# Python
.venv/
__pycache__/
*.pyc
.ipynb_checkpoints/
# Data and artifacts — these belong in S3
data/
model/
*.tar.gz
Two more worth knowing: the IAM role in sagemaker_config.py is not a secret, so committing
that file is fine. And the account ID and bucket name are not secrets either in the security
sense — they are just not public, which is why this guide uses placeholders.
When something goes wrong
aws: command not found — the installer updated your PATH but your terminal was already
open. Close every terminal window and open a new one. If it persists, check the install landed:
Windows
$env:PATH -split ';' | Select-String -Pattern 'AWS'macOS
echo $PATH | tr ':' '\n' | grep -i aws
which awsLinux
echo $PATH | tr ':' '\n' | grep -i aws
which awsUnable to locate credentials — aws configure was never run, or was run as a different
user. Run it again, then aws sts get-caller-identity to confirm.
Claude Code is not using Bedrock — run /status and look at the API provider line. If it
does not say AWS Bedrock, run /setup-bedrock again. If you set it up per project, make sure
you started claude from inside that project folder.
AccessDenied on anything — your IAM permissions do not cover what you tried to do. This is
not something you can fix yourself; send Adam the exact error message and what you were doing.
ResourceLimitExceeded — the account has a cap on how many of a given instance type can run
at once. Wait for your other jobs to finish, use a different instance type, or ask Adam to
request a limit increase from AWS.
A job is stuck in Starting — usually normal. Spot instances wait for capacity, and machine
provisioning takes several minutes even without that. If it is still there after 15 minutes,
describe-training-job will say why.
ValidationException: Could not find model data — the job failed before saving anything.
The real error is in the job’s logs:
aws sagemaker describe-training-job --training-job-name YOUR_JOB_NAME
Read the FailureReason field, then open the job in the SageMaker AI console for the full
CloudWatch log.
ModuleNotFoundError in a training job — the remote container does not have the package.
Container images include the framework and its usual companions, not your whole environment.
Add a requirements.txt next to your training script and SageMaker will install it on the
instance before running your code.
ModuleNotFoundError locally — you ran python instead of uv run python, so you were
outside the project environment.
Getting help
- SageMaker AI documentation and the SageMaker Python SDK reference
- Claude Code on Amazon Bedrock
- AWS service health, when something is broken and it genuinely is not you
Ask Adam about anything to do with access, permissions, billing, or credentials — those are all account-level and you cannot change them yourself. Ask the lab for the ML questions.
When you do ask, include what you were trying to do, the exact error text, and the job name if
there is one. describe-training-job output is usually the whole answer.
Where to go next
Run both examples end to end before adapting anything — it is much easier to tell a broken script from a broken setup when you have seen the setup work.
Then: upload one of your own datasets, change train.py to read from SM_CHANNEL_TRAIN instead
of generating data, and run that. Once that works you have the full loop, and everything after
it is ordinary machine learning.
Worth exploring when you need them: automatic hyperparameter tuning, distributed training across several instances, deploying a model to an endpoint for inference, and SageMaker Pipelines for multi-step workflows.