Chapter 9 — Scenario: Working with the Claude API
You already have experience working with Claude through Claude Code. The
Claude API lets you go further — calling Claude programmatically from your own
Python scripts, building it into applications, automating tasks, and
experimenting with prompts in a controlled environment.
In this chapter you'll build a dedicated Docker container for Claude API
development: a clean Python environment with the Anthropic SDK pre-installed,
your API key handled safely through environment variables (never baked into
an image), and a script structure you can extend for any Claude-powered
project.
What you need: An Anthropic API key from
console.anthropic.com. If you don't have one yet, you can follow
this chapter and substitute a placeholder key — the container setup and code
structure are the same. API keys start with sk-ant-api03-...
An API key is a password. Leak it and anyone who finds it can use your
Anthropic account, run up a bill, and access anything you've built. The
Docker workflow makes it easy to handle keys correctly — but also easy to
get wrong if you're not paying attention.
✅
.env file on your local machine — key lives here, never leaves this machine
SAFE
✅
docker run --env-file .env — injects key as environment variable at runtime
SAFE
✅
os.environ["ANTHROPIC_API_KEY"] — read in Python at runtime, never stored
SAFE
❌
ENV ANTHROPIC_API_KEY=sk-ant-... in Dockerfile — baked into every layer, visible in image history
DANGER
❌
api_key = "sk-ant-..." hardcoded in .py file — one accidental git push and it's public
DANGER
❌
.env file committed to git — git history preserves it even after deletion
DANGER
If you ever accidentally expose a key: Immediately go to
console.anthropic.com → API Keys and revoke it. Create a new key.
Revoking takes effect immediately — the leaked key stops working the moment
you revoke it.
claude-api-dev/
├── Dockerfile
├── requirements.txt
├── .env # ANTHROPIC_API_KEY=sk-ant-... — NEVER commit this
├── .env.example # ANTHROPIC_API_KEY=your-key-here — safe to commit
├── .dockerignore
├── .gitignore
└── scripts/
├── hello_claude.py # Basic API call — start here
├── conversation.py # Multi-turn conversation loop
├── streaming.py # Streaming responses
└── batch_process.py # Process a list of inputs
# .gitignore (and .dockerignore — add .env to both!)
.env
.env.*
!.env.example # allow the example file through
__pycache__
*.pyc
.venv
venv
# .env (your actual key — stays on your machine)
ANTHROPIC_API_KEY=sk-ant-api03-your-real-key-here
# .env.example (placeholder — safe to commit to git)
ANTHROPIC_API_KEY=your-anthropic-api-key-here
# Dockerfile
FROM python:3.12-slim
WORKDIR /workspace
# Install the Anthropic SDK and supporting packages
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Source code will be bind-mounted — no COPY here
# API key will be injected at runtime — no ENV here
CMD ["python", "-c", "print('Claude API dev container ready.')"]
# requirements.txt
anthropic==0.28.0 # Anthropic SDK (pin the version)
python-dotenv==1.0.1 # load .env files in scripts
rich==13.7.1 # pretty terminal output
httpx==0.27.0 # async HTTP (already a dep of anthropic)
Terminal — build the image
$ docker build -t claude-dev:latest .
[+] Building 9.4s (6/6) FINISHED
=> [1/3] FROM python:3.12-slim 2.8s
=> [2/3] COPY requirements.txt . 0.0s
=> [3/3] RUN pip install --no-cache-dir -r requirements.txt 6.1s
# Confirm the SDK is installed
$ docker run --rm claude-dev python -c "import anthropic; print(anthropic.__version__)"
0.28.0
# scripts/hello_claude.py
import os
import anthropic
from dotenv import load_dotenv
# load_dotenv() reads .env from the current working directory
# If the key is already in the environment (injected by Docker), this is a no-op
load_dotenv()
client = anthropic.Anthropic(
api_key=os.environ["ANTHROPIC_API_KEY"]
)
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[
{
"role": "user",
"content": "Explain what Docker is in exactly two sentences."
}
]
)
print(message.content[0].text)
Terminal — run with API key injected safely
# --env-file injects the key at runtime — it is NEVER stored in the image
$ docker run --rm `
--env-file .env `
-v "${PWD}/scripts:/workspace/scripts" `
claude-dev `
python scripts/hello_claude.py
Docker is a platform that packages applications and their dependencies
into lightweight, portable containers that run consistently across any
environment. Unlike virtual machines, containers share the host operating
system kernel, making them faster to start and more efficient with resources.
Understanding the API response object
{
"id": "msg_01XFDUDYJgAACzvnptvVoYEL",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-6",
"content": [
{
"type": "text",
"text": "Docker is a platform that..." ← message.content[0].text
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 18, ← what you sent
"output_tokens": 67 ← what Claude generated (you pay for both)
}
}
Always log token usage during development. Add
print(f"Tokens: {message.usage.input_tokens} in / {message.usage.output_tokens} out")
to your scripts while experimenting. It's easy to accidentally write a loop
that calls the API hundreds of times and run up a large bill — watching the
token count keeps you aware of what you're spending.
| Model ID | Best for | Speed | Cost |
| claude-haiku-4-5-20251001 |
High-volume tasks, classification, summarisation, simple Q&A |
Fastest |
Lowest |
| claude-sonnet-4-6 |
General development, code generation, analysis, most everyday tasks |
Fast |
Mid |
| claude-opus-4-8 |
Complex reasoning, nuanced writing, hard problems requiring deep thought |
Slower |
Higher |
Development tip: Use claude-haiku-4-5-20251001
while building and testing your scripts — it's fast and cheap, so iteration
is painless. Switch to claude-sonnet-4-6 or
claude-opus-4-8 only when you need more capability. A single
model string at the top of your script makes switching trivial.
System prompt
Sets Claude's persona, instructions, or constraints for the entire conversation. Passed separately from the user message.
Use for: giving Claude a role, restricting output format, injecting context
Multi-turn conversation
Pass the full message history on every call — the API is stateless. Your code accumulates the messages list.
Use for: chatbots, interactive tools, anything needing context from earlier turns
Streaming
Receive tokens as they're generated instead of waiting for the full response. Dramatically better UX for long outputs.
Use for: any user-facing interface, long responses, progress feedback
Batch processing
Loop through a list of inputs, call the API for each, collect results. Add a delay or rate-limit check between calls.
Use for: processing files, summarising a list of articles, classifying records
System prompt
# Add a system parameter to messages.create()
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
system="""You are a concise technical writer.
Always respond in plain text, no markdown.
Keep answers under 100 words unless asked for more.""",
messages=[
{"role": "user", "content": "What is a Docker volume?"}
]
)
Multi-turn conversation
# scripts/conversation.py
import os, anthropic
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
history = [] # accumulate the full conversation
print("Chat with Claude (type 'quit' to exit)\n")
while True:
user_input = input("You: ").strip()
if user_input.lower() in ("quit", "exit", ""):
break
history.append({"role": "user", "content": user_input})
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=history # send the full history every time
)
reply = response.content[0].text
history.append({"role": "assistant", "content": reply})
print(f"\nClaude: {reply}\n")
Terminal — run the conversation loop interactively
# -it is essential for interactive input (stdin attached)
$ docker run -it --rm `
--env-file .env `
-v "${PWD}/scripts:/workspace/scripts" `
claude-dev `
python scripts/conversation.py
Chat with Claude (type 'quit' to exit)
You: What is the capital of Hungary?
Claude: Budapest is the capital of Hungary.
You: And what language do they speak there?
Claude: They speak Hungarian (Magyar) — Claude remembers context from the previous turn.
You: quit
Streaming responses
# scripts/streaming.py
import os, anthropic
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
print("Claude: ", end="", flush=True)
# stream=True returns a context manager — tokens arrive as they're generated
with client.messages.stream(
model="claude-sonnet-4-6",
max_tokens=512,
messages=[{"role": "user", "content": "Write a short poem about containers."}]
) as stream:
for text in stream.text_stream():
print(text, end="", flush=True)
print() # newline after streaming finishes
Batch processing a list of inputs
# scripts/batch_process.py
import os, time, anthropic
from dotenv import load_dotenv
load_dotenv()
client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
# Example: classify a list of sentences as positive/negative
sentences = [
"I love learning new things every day.",
"This is incredibly frustrating.",
"Docker makes deployment so much simpler.",
"I can't get this to work no matter what I try.",
]
for i, sentence in enumerate(sentences, 1):
response = client.messages.create(
model="claude-haiku-4-5-20251001", # use Haiku for high-volume batch work
max_tokens=10,
system="Classify the sentiment as exactly one word: positive, negative, or neutral.",
messages=[{"role": "user", "content": sentence}]
)
sentiment = response.content[0].text.strip()
print(f"[{i}/{len(sentences)}] {sentiment:10s} — {sentence}")
time.sleep(0.5) # gentle rate limiting between calls
Terminal — run the batch script
$ docker run --rm `
--env-file .env `
-v "${PWD}/scripts:/workspace/scripts" `
claude-dev `
python scripts/batch_process.py
[1/4] positive — I love learning new things every day.
[2/4] negative — This is incredibly frustrating.
[3/4] positive — Docker makes deployment so much simpler.
[4/4] negative — I can't get this to work no matter what I try.
When you're actively experimenting — trying different prompts, inspecting
response objects, testing ideas — it's more efficient to keep a container
running and exec into it rather than starting a fresh container for every
script run.
Terminal — long-lived dev container
# Start a long-lived container (no --rm, no specific command)
$ docker run -d `
--name claude-dev `
--env-file .env `
-v "${PWD}/scripts:/workspace/scripts" `
claude-dev `
sleep infinity
3a9b1c...
# Run any script without starting a new container each time
$ docker exec claude-dev python scripts/hello_claude.py
$ docker exec claude-dev python scripts/streaming.py
# Open an interactive Python REPL inside the container
$ docker exec -it claude-dev python
>>> import anthropic, os
>>> client = anthropic.Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])
>>> r = client.messages.create(model="claude-haiku-4-5-20251001", max_tokens=50, messages=[{"role":"user","content":"hi"}])
>>> r.content[0].text
'Hello! How can I help you today?'
>>> r.usage
Usage(input_tokens=8, output_tokens=12)
# When done experimenting, stop the container
$ docker stop claude-dev && docker rm claude-dev
save-output.ps1 — capture Claude's response to a file:
docker exec claude-dev python scripts/hello_claude.py | Out-File -Encoding utf8 output.txt
Useful when generating long content you want to review or use elsewhere.
import os, anthropic
from dotenv import load_dotenv
load_dotenv()
# Fail early with a clear message if the key isn't set
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise EnvironmentError("ANTHROPIC_API_KEY not set. Add it to .env or pass with --env-file.")
client = anthropic.Anthropic(api_key=api_key)
try:
message = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1024,
messages=[{"role": "user", "content": "Hello!"}]
)
print(message.content[0].text)
except anthropic.AuthenticationError:
print("Invalid API key — check your .env file")
except anthropic.RateLimitError:
print("Rate limit hit — slow down or upgrade your plan")
except anthropic.APIConnectionError:
print("No connection to Anthropic API — check your network")
except anthropic.APIStatusError as e:
print(f"API error {e.status_code}: {e.message}")
Exercises
- Run hello_claude.py. Create all the files from Steps 2–4 (Dockerfile, requirements.txt, .env with your real key, .dockerignore, .gitignore, and scripts/hello_claude.py). Build the image and run the script with
--env-file .env. Confirm Claude's response appears in the terminal. Then run docker run --rm claude-dev env | grep ANTHROPIC to see the key is injected — and run docker image history claude-dev:latest to confirm it is NOT baked into any image layer.
- Experiment with models. Edit hello_claude.py to use
claude-haiku-4-5-20251001 instead of Sonnet. Add a line to print the token usage after the response. Run it with the same prompt and note the token counts. Try the same with Sonnet. Is the response quality noticeably different for a simple question? Try a harder question where Haiku struggles.
- Build the conversation loop. Create scripts/conversation.py from Step 6 and run it with
docker run -it. Have a multi-turn conversation about something you're learning (Japanese, Docker, Python — anything). After you quit, notice that the next time you run it, Claude has no memory of the previous session — because history was stored in a Python list that was discarded when the container exited. Think about how you'd save that history to a file to persist it across runs.
- Streaming vs non-streaming. Create scripts/streaming.py and run it. Notice how the response appears word-by-word instead of all at once after a delay. Now change the prompt to ask for something long (e.g. "Write a detailed explanation of how Docker networking works"). The difference in perceived responsiveness between streaming and non-streaming is most obvious with longer responses.
- Build something useful for your own work. Write a new script that uses Claude to help with something you actually do. Examples: summarise a webpage (paste in text), generate a lesson plan outline, translate a short paragraph to Hungarian or Japanese, review a piece of Python code you're working on, or generate test cases for a function. The goal is to go from "API example" to "tool I'd actually use" — even if it's simple.
Next: Chapter 10 — Docker Compose: Running Multi-Container Apps
Throughout this course you've been starting containers one by one with
docker run. Chapter 10 introduces Docker Compose — a single YAML
file that defines your entire multi-container stack (web server, database,
cache, tunnel) and brings it all up with one command. It's the natural
endpoint of the course, and the gateway to everything beyond: production
deployments, CI pipelines, and the intermediate Docker course.