Describe the deployment artifact: the container

Outcome
You can name every stage of the agent's Dockerfile, explain the port-8080 / POST /invocations / GET /ping contract the container must satisfy, and run a local docker build that produces an image tagged intake-agent.
Prerequisites
Lesson 0 complete (Docker installed, an OpenRouter API key obtained), Lesson 7 complete (a tested agent ready to package), Lesson 1 concepts (the publish-workflow idea)
Cost
FreeFree. Reading the Dockerfile costs nothing, and a local docker build only spends your machine's own CPU and disk — no account, no credentials, no cloud call.

Outcome

By the end of this lesson you will read the one file that packages the capstone agent as a container image, and you will be able to name every stage it goes through — base image, dependency install, source copy, the port it exposes, and the command it runs. You will understand the runtime contract the container must satisfy (listen on port 8080, answer POST /invocations and GET /ping), and you will build the image once on your own machine. All of this is free: reading a file and running docker build locally touches no account and no cloud service.

This is the local track’s version of Lesson 8. The AWS track reads two CDK (Cloud Development Kit) stacks that describe cloud resources; the local track reads one Dockerfile that describes a container — a much smaller artifact, because there is no cloud to provision. Both lessons do the same job: read the deployment artifact before you deploy it.

Mental model

In a visual automation tool, pressing Publish takes the workflow you built and makes it the real, running thing — you rarely think about what “the thing” actually is. A container image makes that concrete: it is a sealed box holding your code, the exact Python runtime it needs, and every locked dependency, built once from a text recipe called a Dockerfile. docker build reads that recipe and produces the box; docker run (Lesson 9) opens the box and runs it.

The Dockerfile is the artifact you review before you trust it — the same role cdk synth’s output plays on the AWS track. Where the AWS track’s artifact is a CloudFormation template describing cloud resources, the local track’s artifact is a container image describing a runnable process. Both are short, reviewable descriptions of something bigger they produce.

One detail matters and has no visual-tool equivalent: whatever runs inside the container must speak a fixed contract so its host can supervise it — start it, health-check it, and send it work. Amazon Bedrock AgentCore Runtime (the AWS track’s host) and your own machine (the local track’s host, via plain docker run) both expect the exact same contract: a server on port 8080 that answers POST /invocations for one agent turn and GET /ping for a health check. That shared contract is why one image can serve both tracks — the box looks identical to whoever is running it.

Where the analogy stops: publishing in a visual tool usually deploys to the vendor’s servers immediately. Building an image here changes nothing yet — it only produces a box sitting on your disk. Lesson 9 is where you actually run it.

Prerequisites and cost

  • Lesson 0 complete: Docker installed (docker --version prints a version) and an OpenRouter API key obtained (you will use it in Lesson 9, not here).
  • Lesson 7 complete: the agent code is tested and ready to package.

Cost: free. Reading agent/Dockerfile costs nothing, and docker build only uses your own machine’s CPU, memory, and disk to produce a local image — it contacts no paid service and needs no credentials. (The base image and package downloads use your internet connection, same as any other software install.)

The container’s job: one contract, two tracks

Whatever runs inside the container must satisfy the same runtime contract that Amazon Bedrock AgentCore Runtime requires in the cloud: listen on port 8080, expose POST /invocations for one agent turn, and GET /ping for a health check. You do not write that HTTP server yourself. BedrockAgentCoreApp, imported in agent/src/intake/runtime_app.py (read in full in Lesson 9), implements the contract regardless of which track calls it — the name mentions AgentCore because that library shipped for the AWS track, but nothing about the contract itself is AWS-specific: a port and two HTTP routes are just a port and two HTTP routes. The same server that answers AgentCore’s calls in the cloud answers your curl calls on localhost next lesson.

Now read the recipe that builds the box around that server.

# Lesson 9: container image for Amazon Bedrock AgentCore Runtime.
#
# The runtime contract only requires that the container listen on port 8080 and
# expose POST /invocations and GET /ping; BedrockAgentCoreApp (in runtime_app.py)
# provides both. AgentCore Runtime expects linux/arm64 images, so build with:
#   docker build --platform linux/arm64 -t intake-agent .
# ARM64 requirement, port, and paths follow
# https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-http-protocol-contract.html

# Pinned base for reproducibility. Prefer pinning by digest in real deployments.
FROM python:3.12-slim-bookworm

# uv gives the same locked install inside the container as on a learner's machine.
# Pinned tag (see docs/research-log.md: uv 0.11.28).
COPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /uvx /bin/

WORKDIR /app

# Copy manifests first so dependency install is cached separately from source.
COPY pyproject.toml uv.lock .python-version ./
COPY src ./src
COPY data ./data

# --frozen: fail if the lockfile is stale. --no-dev: no test tooling in the image.
# --extra openrouter installs the OpenAI client so ONE image serves both tracks:
# the AWS track ignores it; the local track calls OpenRouter through it. (An
# optional extra is not installed by a bare `uv sync`, so it must be named here.)
RUN uv sync --frozen --no-dev --extra openrouter

EXPOSE 8080

# Runs app.run(), which starts the AgentCore HTTP server on port 8080.
CMD ["uv", "run", "--no-dev", "python", "-m", "intake.runtime_app"]

Every stage, named

Walk the file top to bottom:

  1. Base imageFROM python:3.12-slim-bookworm. A small, pinned Debian-based Python 3.12 image. Pinning (rather than python:3.12 or python:latest) means the build is reproducible: the same tag produces the same base weeks later.
  2. The uv binaryCOPY --from=ghcr.io/astral-sh/uv:0.11.28 /uv /uvx /bin/. This copies the uv tool itself out of its own published image, pinned to the exact version this course uses elsewhere, so the container installs dependencies the identical way your own machine does.
  3. Working directoryWORKDIR /app. Every following instruction runs from /app inside the image.
  4. Manifests firstCOPY pyproject.toml uv.lock .python-version ./. Copying only the dependency-defining files before the source code is a deliberate layer-ordering trick: Docker caches each instruction as a layer, and this layer only changes (forcing a slow reinstall) when a dependency changes — not every time you edit a line of Python.
  5. Source and dataCOPY src ./src and COPY data ./data. Now the actual code and the local systems catalog land in the image, in their own layer.
  6. Dependency installRUN uv sync --frozen --no-dev --extra openrouter. Three flags, three decisions:
    • --frozen fails the build if uv.lock is out of sync with pyproject.toml, instead of silently re-resolving different versions.
    • --no-dev skips the test toolchain (pytest), since the running container never needs it.
    • --extra openrouter is the important one: it installs the OpenAI-compatible client Strands needs to call OpenRouter. This one flag is why a single image serves both tracks — the AWS track’s code never imports that client and simply ignores it being present, while the local track depends on it being there. Without --extra openrouter, this same image would only work for the AWS track.
  7. Exposed portEXPOSE 8080. Documents the contract port from the section above; it does not by itself publish the port (Lesson 9’s docker run -p does that).
  8. EntrypointCMD ["uv", "run", "--no-dev", "python", "-m", "intake.runtime_app"]. The command the container actually runs when started. It launches runtime_app.py, whose app.run() call starts the port-8080 server described above.

The one difference from the AWS track: no --platform flag

The Dockerfile’s own top comment says AgentCore Runtime expects linux/arm64 images, and the AWS track’s build command (Lesson 9 on that track) forces that target with docker build --platform linux/arm64 .... That flag exists only because AgentCore Runtime is a specific managed host with a fixed processor architecture, and a learner’s laptop is very often a different one (Intel/AMD amd64), so the AWS build has to cross-compile.

The local track has no such constraint. You are building the image to run on the very machine you build it on, so a plain, unflagged docker build already targets your machine’s native architecture — Apple Silicon builds an arm64 image, an Intel or AMD machine builds an amd64 image, and either is exactly right for docker run on that same machine. There is nothing to force and nothing to get wrong here.

Steps

Run these from the agent/ folder.

  1. Confirm Docker is available:

    docker --version
  2. Build the image, tagging it intake-agent so Lesson 9 can refer to it by name. No --platform flag — the build targets your machine’s own processor:

    docker build -t intake-agent .

Expected output

docker --version prints something like Docker version 27.x.x, build <hash> (your exact version will vary).

The build prints one numbered step per Dockerfile instruction, each ending DONE (or CACHED on a repeat build), and finishes with a line naming the tag it produced:

 => [1/6] FROM docker.io/library/python:3.12-slim-bookworm
 => [2/6] COPY --from=uv /uv /uvx /bin/
 => [3/6] WORKDIR /app
 => [4/6] COPY pyproject.toml uv.lock .python-version ./
 => [5/6] RUN uv sync --frozen --no-dev --extra openrouter
 => [6/6] COPY src ./src
 => exporting to image
 => => naming to docker.io/library/intake-agent

Confirm the image exists locally:

docker images intake-agent

which lists one row with REPOSITORY intake-agent and a TAG latest.

One common failure

Symptom: docker build fails at the uv sync --frozen ... step with an error mentioning the lockfile is out of date, or docker: command not found before the build even starts.

Diagnosis: two different causes with the same symptom shape. If the error names uv.lock, pyproject.toml changed (a dependency was added or edited) without regenerating the lockfile — --frozen refuses to guess. If docker itself is missing, Docker was not installed in Lesson 0, or Docker Desktop / the Docker daemon is not running.

Fix: for a stale lockfile, run uv lock from agent/ to regenerate it, then rebuild. For a missing docker command, install Docker (or start Docker Desktop) per Lesson 0, confirm with docker --version, then rebuild.

Why this works

The Dockerfile is short because most of what it does is delegate: uv handles the exact, locked dependency install, and BedrockAgentCoreApp (used inside runtime_app.py, read in full next lesson) handles the HTTP contract. What the Dockerfile itself controls is narrow and reviewable — the base image, the install flags, and the final command — which is why you can read the whole thing in one sitting and know exactly what ships. The --extra openrouter flag is the single line that makes one image cover both tracks, and skipping the --platform flag is correct here specifically because the local track’s host and build machine are the same computer, unlike the AWS track’s managed, fixed-architecture runtime.

Verify it yourself

The checkpoint is: you can name every stage of the Dockerfile and explain the port-8080 contract.

  1. Without looking back at the file, list the Dockerfile’s stages in order (base image, uv copy, working directory, manifest copy, source/data copy, dependency install, expose, command) and check yourself against the numbered list above.
  2. Explain in one sentence what POST /invocations and GET /ping are for, and name the library that implements them (BedrockAgentCoreApp).
  3. Explain why docker build -t intake-agent . needs no --platform flag on the local track, when the AWS track’s equivalent command requires one.
  4. Confirm docker images intake-agent lists the image you just built.

Naming every stage, explaining the contract, and seeing the built image are this lesson’s checkpoint — you now know exactly what the box you are about to run contains.

Cleanup

Nothing billable or persistent was created — a local image sitting on your disk costs nothing to keep. If you want to reclaim the disk space before continuing, you can remove it (Lesson 9 rebuilds it anyway):

docker rmi intake-agent

Otherwise, leave the image in place — Lesson 9 runs it next.

Go deeper (1)