Describe the cloud in code with AWS CDK

Outcome
You run cdk synth successfully, read the agent-runtime stack (and the small registry stack beside it), and can name every resource they create (2 in the registry stack, 11 in the agent stack) plus the exact model and memory ARNs the agent's execution role is allowed to use.
Prerequisites
Lesson 0 complete (uv, Python, Node.js, the CDK CLI, and the AWS CLI installed), Lesson 7 complete (a tested agent ready to deploy), Lesson 1 concepts (the publish-workflow idea)
Cost
FreeFree. cdk synth reads local files and writes local files only. It needs no AWS credentials and creates nothing in the cloud, so it spends nothing. The only output is a cdk.out/ folder on your disk.

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 example CfnBucket). 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.Bucket is an L2: it is far shorter than CfnBucket and 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: uv and 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 with uv --version, node --version, and cdk --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.

  1. 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
  2. Synthesize both stacks:

    cdk synth

    This writes both templates into cdk.out/. Because the app has two stacks, cdk synth prints an INFO note and Supply a stack id ... to display its template instead of a template. Name the agent stack to print it:

    cdk synth WorkflowsToAgentsAgent

    You will also see one INFO note about deferred container-image validation — expected, and explained below.

  3. 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.

  1. Run cdk synth from infra/ and confirm it prints templates without error.
  2. Open cdk.out/WorkflowsToAgentsAgent.template.json and find the eleven resources listed above. Confirm the AWS::BedrockAgentCore::Runtime and AWS::BedrockAgentCore::Memory resources are both present.
  3. Find the InvokeGptOss statement in the template and confirm its Resource names the model ARN, not *. Confirm the execution role can also pull from the workflows-to-agents-intake repository (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.

Go deeper (2)