Describe the cloud in code with AWS CDK
Outcome
By the end of this lesson you will read the two files that define all of the capstone’s cloud, and you will run one command that turns them into the exact templates AWS (Amazon Web Services) would deploy. You will be able to point at the agent stack and name every resource it creates — 11 in all, plus 2 in a small image-registry stack beside it — and you will see, in the synthesized output, that the agent’s permissions are scoped to named model and memory resources rather than “everything.” All of this happens on your machine, for free, with no AWS account touched.
This is the first cloud lesson, and it deliberately deploys nothing. You look before you leap.
Mental model
In a visual automation tool, you build a workflow by dragging nodes onto a canvas, then press Publish. Publishing takes the design you drew and turns it into the real, running thing. Infrastructure as code is that same split, written down: you describe the cloud you want in a file, and a tool turns that description into real resources. The description is reviewable, versioned, and repeatable, the same way a saved workflow is.
The tool here is the AWS Cloud Development Kit (CDK). You write Python that
declares resources — a storage bucket, a container repository, an agent runtime —
and CDK synthesizes that code into a CloudFormation template. CloudFormation is
AWS’s own deployment engine: a template is a long, exact document listing every
resource and property. You rarely write that document by hand; CDK generates it
from the much shorter code you read below. The command that does this is
cdk synth. It is the “show me exactly what Publish would create” button, and it
runs without deploying anything.
CDK organizes code into constructs, which come in three levels:
- L1 constructs map one-to-one to a raw CloudFormation resource. Their names
start with
Cfn(for exampleCfnBucket). They expose every property AWS offers and none of the convenience. Think of the rawest possible node with every setting exposed. - L2 constructs are curated wrappers around L1. They pick safe defaults, add
helper methods, and are what you use most of the time.
s3.Bucketis an L2: it is far shorter thanCfnBucketand defaults to sensible, secure settings. - L3 constructs (also called patterns) bundle several L2 constructs into one common architecture. This course does not need any.
Where the analogy stops: pressing Publish in a visual tool usually updates one
workflow in place. cdk synth produces a full template but changes nothing; the
change happens only later, in Lesson 9, when you deliberately deploy. Synth is
safe to run as often as you like.
Prerequisites and cost
- Lesson 0 complete:
uvand Python 3.12 (for the CDK app), plus Node.js 22.12 or newer and the CDK CLI (cdk, an npm tool) and the AWS CLI. Check withuv --version,node --version, andcdk --version. - Lesson 7 complete: the agent code is tested and ready.
Cost: free. cdk synth reads the two stack files, then writes the templates into a
local cdk.out/ folder. It calls no AWS service and needs no credentials, so it
cannot bill anything. Deployment (which does cost money) is the next lesson.
Steps
Run these from the infra/ folder.
-
Install the CDK library (
aws-cdk-lib) into the app’s virtual environment. The CDK CLI (cdk) itself was installed in Lesson 0:uv sync -
Synthesize both stacks:
cdk synthThis writes both templates into
cdk.out/. Because the app has two stacks,cdk synthprints an INFO note andSupply a stack id ... to display its templateinstead of a template. Name the agent stack to print it:cdk synth WorkflowsToAgentsAgentYou will also see one INFO note about deferred container-image validation — expected, and explained below.
-
List the stacks to confirm both were found:
cdk list
Smallest code sample
Two classes, each describing one stack. Notice how short they are compared to the templates they generate.
First, the tiny registry stack. Its only job is to hold the agent’s container repository (ECR, Elastic Container Registry). It lives in its own stack for a deploy-ordering reason you will use in Lesson 9: the AgentCore Runtime checks that its image exists the moment it is created, so the repository must exist — and hold a pushed image — before the agent stack deploys. A separate stack makes that order explicit: deploy the registry, push the image, then deploy the agent.
from aws_cdk import (
CfnOutput,
RemovalPolicy,
Stack,
)
from aws_cdk import aws_ecr as ecr
from constructs import Construct
# Fixed repository name so the agent stack can reference this repo BY NAME
# (ecr.Repository.from_repository_name) instead of through a CloudFormation
# cross-stack export/import. The two stacks then share no CloudFormation
# dependency -- they deploy and tear down independently, matching the course's
# "stacks share no cross-stack references" rule.
REPOSITORY_NAME = "workflows-to-agents-intake"
class EcrStack(Stack):
"""The agent's container registry, in its own stack.
Why a separate stack: the AgentCore Runtime validates its container image at
CREATE time, so the image must already be in ECR when the agent stack
deploys. Keeping the repository here makes the deploy order deterministic and
free of failed-deploy workarounds:
1. deploy this stack -> the (empty) repository exists
2. build + push the image -> the tag the runtime wants now exists
3. deploy the agent stack -> the runtime finds its image and starts
(If the repository lived in the agent stack, the first deploy would fail on
the runtime because the repo is created empty in the same deploy -- the
chicken-and-egg this split removes.)
"""
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
repository = ecr.Repository(
self,
"AgentImageRepository",
repository_name=REPOSITORY_NAME,
image_scan_on_push=True,
# Disposable env: destroying the stack also deletes the repo AND its
# images (empty_on_delete), so no ECR images are left billing.
removal_policy=RemovalPolicy.DESTROY,
empty_on_delete=True,
# Bound stored images to control ECR storage cost during the tutorial.
lifecycle_rules=[ecr.LifecycleRule(max_image_count=5)],
)
CfnOutput(
self,
"EcrRepositoryUri",
value=repository.repository_uri,
description="Push the agent image here before deploying the agent stack.",
)
Second, the agent stack — the substantial one. It runs the agent on an AgentCore
Runtime, keeps an AgentCore Memory for the cross-session preference from Lesson 6,
and grants a least-privilege execution role. It references the repository from the
first stack by name, so the two stacks share no CloudFormation dependency and
tear down independently. The AgentCore constructs are stable L2 constructs from
aws_cdk.aws_bedrockagentcore.
One piece looks heavier than it reads: log retention. AgentCore creates its own
log group when the runtime is provisioned, with no retention (logs kept forever
cost money). The stack cannot just declare that group — the service already owns
it — so it uses a small custom resource that calls one AWS API
(PutRetentionPolicy) to cap retention at 30 days, and deletes the group on
teardown. A custom resource is CDK’s escape hatch for “call an AWS API during
deploy”; it is implemented by a helper Lambda function CDK generates for you. That
one line of intent is why the agent stack’s resource count jumps — the helper
Lambda, its role, its policy, and its own log group all appear in the template.
from aws_cdk import (
ArnFormat,
CfnOutput,
Duration,
Stack,
)
from aws_cdk import aws_bedrockagentcore as agentcore
from aws_cdk import aws_ecr as ecr
from aws_cdk import aws_iam as iam
from aws_cdk import custom_resources as cr
from constructs import Construct
from stacks.ecr_stack import REPOSITORY_NAME
# Configuration for the capstone runtime. Kept as named constants (overridable
# via `cdk.json` context / `-c`) rather than hard-coded inline, per AGENTS.md
# "Keep model ID, region, memory ID ... configurable". Defaults match
# docs/research-log.md.
# OpenAI gpt-oss-120b on Bedrock. An open-weight model called directly by its
# foundation-model id, in-region -- it has no commercial cross-region inference
# profile and needs no model-access request (per the model card: In-Region
# available in us-east-2; Converse API supported).
DEFAULT_MODEL_ID = "openai.gpt-oss-120b-1:0"
# Region the tutorial deploys to and where Bedrock is called. us-east-2 has the
# model In-Region and full AgentCore Runtime/Memory support (per the model card
# and AgentCore region table).
DEFAULT_REGION = "us-east-2"
# Container image tag the runtime pulls from ECR. Learners build + push this tag
# in Lesson 9 (see README push flow) before `cdk deploy`.
DEFAULT_IMAGE_TAG = "latest"
class AgentStack(Stack):
"""Capstone runtime stack: an Amazon Bedrock AgentCore Runtime that runs the
containerized intake agent, an AgentCore Memory (short-term events + a
long-term SEMANTIC strategy), and a least-privilege execution role.
"""
def __init__(self, scope: Construct, id: str, **kwargs) -> None:
super().__init__(scope, id, **kwargs)
model_id = self.node.try_get_context("modelId") or DEFAULT_MODEL_ID
region = self.node.try_get_context("region") or DEFAULT_REGION
image_tag = self.node.try_get_context("imageTag") or DEFAULT_IMAGE_TAG
# -------------------------------------------------------------------
# Container artifact: the ECR repository lives in its own stack (EcrStack)
# so the image can be pushed before this runtime, which validates the
# image at CREATE time, is deployed. We reference the repo BY NAME, which
# imports it without a CloudFormation cross-stack dependency -- the two
# stacks stay independent. The image itself (from_asset) is deliberately
# NOT built at synth time: it targets linux/arm64 and needs qemu/network,
# which would make `cdk synth` non-reproducible. The learner builds +
# pushes the arm64 image in Lesson 9. (Documented deviation -- see README.)
repository = ecr.Repository.from_repository_name(
self,
"AgentImageRepository",
REPOSITORY_NAME,
)
artifact = agentcore.AgentRuntimeArtifact.from_ecr_repository(
repository,
image_tag,
)
# -------------------------------------------------------------------
# Memory: short-term raw events + a long-term SEMANTIC extraction strategy.
# -------------------------------------------------------------------
# Prop names match the installed aws-cdk-lib 2.261.0 API:
# - `expiration_duration` sets short-term (raw event) retention (7-365
# days, default 90). Bounded to 30 days here for a disposable env.
# - `memory_strategies` holds long-term extraction strategies;
# MemoryStrategy.using_built_in_semantic() -> a managed SEMANTIC strategy.
memory = agentcore.Memory(
self,
"IntakeMemory",
memory_name="intake_memory",
expiration_duration=Duration.days(30),
memory_strategies=[agentcore.MemoryStrategy.using_built_in_semantic()],
)
# Teardown: AWS::BedrockAgentCore::Memory has no L2 removal-policy prop, and
# CloudFormation's default deletion policy (Delete) already removes the memory
# -- and its stored records -- on `cdk destroy`. No escape hatch is needed;
# the synthesized template carries no DeletionPolicy (= Delete). The L2 also
# auto-creates a memory service role (trust scoped to this memory's ARN) that
# is deleted with the stack.
# -------------------------------------------------------------------
# Runtime. The L2 auto-creates an execution role with a correct trust policy
# (bedrock-agentcore.amazonaws.com + aws:SourceAccount/SourceArn confused-
# deputy conditions) and injects a baseline set of execution-role statements
# (logs, X-Ray, CloudWatch metrics, workload identity). We add ONLY the two
# scoped statements the baseline does not cover: Bedrock model invocation and
# AgentCore Memory access. See README "IAM" for the full inventory, including
# the construct-injected wildcard-resource statements AWS requires.
# -------------------------------------------------------------------
runtime = agentcore.Runtime(
self,
"IntakeAgentRuntime",
runtime_name="intakeAgent",
agent_runtime_artifact=artifact,
description="Bounded automation intake agent (capstone).",
environment_variables={
# Consumed by agent/src/intake/config.py (MODEL_ID, AWS_REGION, MEMORY_ID).
"MODEL_ID": model_id,
"AWS_REGION": region,
"MEMORY_ID": memory.memory_id,
},
)
# --- Bedrock model invocation (scoped, no wildcards) ---------------
# Exactly the two actions the Strands BedrockModel needs: non-streaming and
# streaming inference. gpt-oss-120b is called in-region by its foundation-
# model id (no inference profile), so ONE account-less foundation-model ARN
# in the deploy region is the whole grant.
runtime.add_to_role_policy(
iam.PolicyStatement(
sid="InvokeGptOss",
effect=iam.Effect.ALLOW,
actions=["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
resources=[
Stack.of(self).format_arn(
service="bedrock",
region=region,
account="", # foundation-model ARNs have no account id
resource="foundation-model",
resource_name=model_id,
)
],
)
)
# --- AgentCore Memory access (scoped to this memory's ARN) ---------
# grant_write -> CreateEvent (write conversation turns).
# grant_read -> Get/List events + Get/Retrieve/List long-term memory records.
# Both grants set resource_arns to memory.memory_arn only, so nothing else
# is reachable.
memory.grant_write(runtime)
memory.grant_read(runtime)
# -------------------------------------------------------------------
# Log-group retention (via a custom resource, not a CDK LogGroup).
# -------------------------------------------------------------------
# AgentCore creates its application log group
# (/aws/bedrock-agentcore/runtimes/{id}-DEFAULT) itself, at runtime-PROVISION
# time, with NO retention (logs kept forever = cost). A plain CDK LogGroup at
# the same name loses a race: the service has already created the group, so
# the CloudFormation resource fails with AlreadyExists (observed live
# 2026-07-16). Instead, this custom resource sets retention on the
# already-existing service group after the runtime is created, and deletes
# the group on teardown so nothing is left behind.
log_group_name = (
f"/aws/bedrock-agentcore/runtimes/{runtime.agent_runtime_id}-DEFAULT"
)
log_group_arn = Stack.of(self).format_arn(
service="logs",
resource="log-group",
resource_name=f"{log_group_name}:*",
arn_format=ArnFormat.COLON_RESOURCE_NAME,
)
set_retention = cr.AwsSdkCall(
service="CloudWatchLogs",
action="putRetentionPolicy",
parameters={"logGroupName": log_group_name, "retentionInDays": 30},
physical_resource_id=cr.PhysicalResourceId.of(log_group_name),
)
retention = cr.AwsCustomResource(
self,
"RuntimeLogRetention",
on_create=set_retention,
on_update=set_retention,
on_delete=cr.AwsSdkCall(
service="CloudWatchLogs",
action="deleteLogGroup",
parameters={"logGroupName": log_group_name},
),
policy=cr.AwsCustomResourcePolicy.from_statements(
[
iam.PolicyStatement(
effect=iam.Effect.ALLOW,
actions=["logs:PutRetentionPolicy", "logs:DeleteLogGroup"],
resources=[log_group_arn],
)
]
),
# The SDK ships in the Lambda runtime; do not install a fresh copy.
install_latest_aws_sdk=False,
)
# The service group only exists once the runtime is provisioned, so the
# retention call must run after the runtime is created.
retention.node.add_dependency(runtime)
CfnOutput(
self,
"RuntimeArn",
value=runtime.agent_runtime_arn,
description="ARN of the AgentCore Runtime (invoke target for Lesson 9).",
)
CfnOutput(
self,
"MemoryId",
value=memory.memory_id,
description="AgentCore Memory id -- set as MEMORY_ID when running locally.",
)
Least privilege, made concrete
The most important part of the agent stack is what the runtime is allowed to
do. The Runtime construct attaches a baseline set of permissions you cannot opt
out of, and this code adds exactly two scoped statements on top: permission to call
the one model, and permission to use the one memory.
The model statement is the one to study. It grants only two actions
(bedrock:InvokeModel and bedrock:InvokeModelWithResponseStream) and scopes them
to exactly one named resource — the gpt-oss-120b foundation model in the deploy
region. This is the real, synthesized statement (captured on 2026-07-16):
{
"Sid": "InvokeGptOss",
"Effect": "Allow",
"Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"],
"Resource": "arn:<PARTITION>:bedrock:us-east-2::foundation-model/openai.gpt-oss-120b-1:0"
}
<PARTITION> is filled in at deploy time from your account; CDK leaves it as a
reference in the template. Foundation-model ARNs (Amazon Resource Names) carry no
account id by design — the model belongs to the service, not to you. Because
gpt-oss-120b is called in-region by its own model id, one ARN covers the whole
invocation path; a model routed through a cross-region inference profile would
instead need the profile ARN plus a foundation-model ARN per routable region.
Some baseline statements do use Resource: "*". That is not sloppiness; a few AWS
APIs take no resource ARN at all. For example, ecr:GetAuthorizationToken issues a
registry-wide login token with no resource to name, the X-Ray trace-upload actions
accept no ARN, and cloudwatch:PutMetricData publishes a metric with no resource
to name (the stack still narrows it with a cloudwatch:namespace condition). Every
such wildcard pairs a narrow, emit-or-authenticate action with an API AWS does not
let you scope by ARN. There is no
Action: "*" and no broad-action Resource: "*" anywhere. The full statement-by-
statement inventory is in infra/README.md.
Expected output
cdk synth runs locally, so this is real output, captured on 2026-07-16. First,
cdk list shows the two stacks:
WorkflowsToAgentsEcr
WorkflowsToAgentsAgent
Then cdk synth WorkflowsToAgentsAgent prints that stack’s template. Your run first
prints one INFO note (container image validation deferred to deploy time —
expected, the image reference is a placeholder until deploy). This is the top of
the real output, trimmed to the first two resources with some properties
(tags, metadata) omitted for space:
Description: Amazon Bedrock AgentCore Runtime + Memory for the capstone intake agent.
Resources:
IntakeMemoryServiceRole2496B026:
Type: AWS::IAM::Role
IntakeMemoryF1629C59:
Type: AWS::BedrockAgentCore::Memory
Properties:
EventExpiryDuration: 30
The checkpoint for this lesson is that synth succeeds and you can name every resource. The registry stack creates 2 resources: 1 ECR repository (the container image store) and 1 CDK metadata resource.
The agent stack creates 11 resources:
- 1 AgentCore Memory and its auto-created memory service role (an IAM (Identity and Access Management) role)
- 1 AgentCore Runtime and its auto-created execution role (an IAM role)
- 1 IAM policy (the two scoped statements above, attached to the execution role)
- The log-retention custom resource and the four pieces CDK generates to run it: 1 helper Lambda function, its IAM role, its IAM policy, and its own log group
- 1 CDK metadata resource (added to every stack)
The five custom-resource entries are all plumbing for that one PutRetentionPolicy
call — worth recognizing so they do not surprise you, but the agent, memory, and
execution role are the resources that matter.
If synth prints the template, cdk list shows both stacks, and you can point at each
resource above in the cdk.out/*.template.json files, you have hit the checkpoint.
One common failure
Symptom: cdk synth fails immediately — with cdk: command not found, a
ModuleNotFoundError: No module named 'aws_cdk', or a message that your Python
version is unsupported — before any template is printed.
Diagnosis: either the CDK CLI is not installed or the app’s Python
dependencies are missing. The cdk command is the Node-based CLI from Lesson 0;
aws-cdk-lib is the Python library uv sync installs into infra/.venv. If you
skipped uv sync in infra/, there is no aws_cdk module to import; if cdk
itself is missing, revisit Lesson 0. The app needs Python 3.12 (uv provides it).
Fix: from infra/, run uv sync, confirm cdk --version prints a version,
then run cdk synth again.
Why this works
Reading the stacks before deploying is the whole point. The code is short because
L2 constructs carry safe defaults, but cdk synth expands it into the full, exact
CloudFormation template AWS will act on, so you review the real thing rather than a
summary. Because synth only reads and writes local files, you can inspect that
template, count its resources, and check the IAM scope as many times as you want
without spending a cent or creating anything. And the synthesized policy shows least
privilege as a fact, not a promise: the agent may invoke exactly one model
and one memory, named by ARN, and every wildcard that remains is one AWS itself
requires for an action that has no resource to name. You now know precisely what
Lesson 9 will create before it creates it.
Verify it yourself
The checkpoint is: synth succeeds and you can name every resource in each stack.
- Run
cdk synthfrominfra/and confirm it prints templates without error. - Open
cdk.out/WorkflowsToAgentsAgent.template.jsonand find the eleven resources listed above. Confirm theAWS::BedrockAgentCore::RuntimeandAWS::BedrockAgentCore::Memoryresources are both present. - Find the
InvokeGptOssstatement in the template and confirm itsResourcenames the model ARN, not*. Confirm the execution role can also pull from theworkflows-to-agents-intakerepository (an ECR grant scoped to that repo’s ARN), even though the repository is defined in the other stack.
Seeing the template, counting the resources, and reading the scoped IAM statement is this lesson’s checkpoint: you can describe the entire cloud footprint before any of it exists.
Cleanup
Nothing billable was created, so there is nothing in the cloud to remove. cdk synth only wrote the local cdk.out/ folder. If you want a clean working
directory you can delete it:
rm -rf cdk.out
It will be regenerated the next time you run synth. You now understand the exact resources the next lesson deploys, and why the agent’s permissions are as narrow as they are.


