When I first tried to plug **Claude‑3.5‑Sonnet** into my desktop automation script, the app crashed the moment the API started streaming a response. The UI froze, the container silently died, and I lost hours of state because the process didn’t survive a restart. The root cause wasn’t the model—it was the lack of a reproducible environment and a proper event‑loop that could juggle SSE streams, GUI updates, and persistent state.
Docker solves that mess in one fell swoop: it locks down every library version, isolates the graphics stack, and lets you spin the same image on a laptop, a CI runner, or a cloud VM without “it works on my machine” surprises. The following guide shows you how to build a production‑ready Claude agent, stream responses into a live PyQt widget, keep session data across container restarts, and handle secrets the right way.
- Use a multi‑stage Dockerfile based on
python:3.11-slim‑bookwormfor a small, reliable image. - Run the Claude client in its own process and forward SSE events to the GUI via a thread‑safe queue.
- Persist session state in Redis (or a bind‑mounted SQLite file) and wire it with Docker‑Compose.
- Inject the Anthropic API key with Docker secrets or runtime environment variables—never bake it into the image.
- Add health checks, resource limits, and a CI pipeline that builds, tests, and pushes the image automatically.
Before you start: Docker Engine v25.x, Docker Compose v2.24+, Python 3.11+, Anthropic SDK v1.8+, a valid Claude API key (for claude-3-5-sonnet-20241022), and optionally a GPU‑enabled host with nvidia-docker2 for faster embeddings.
Containerizing a Claude Agent App with Docker
Containerizing a Claude Agent App with Docker involves creating a Dockerfile to package the Python code, Anthropic SDK, GUI dependencies (like PyQt), and configurations. Use a multi‑stage build for optimization and docker‑compose to manage services. This ensures the agent runs identically anywhere, from a developer’s laptop to a cloud server, guaranteeing reproducible deployments.
Why Docker is Essential for Claude Agent Deployability in 2026
The Rise of AI Agent Deployments
AI agents are no longer one‑off scripts; they’re services that interact with files, browsers, and even the OS. In 2026 the market expects agents to be **reproducible**, **scalable**, and **secure** out of the box—requirements that line up perfectly with container technology.
Solving the “It Works on My Machine” Headache
A typical Claude agent pulls in `anthropic`, `numpy`, `PyQt6`, and sometimes `torch` for embeddings. Those wheels compile against glibc, OpenSSL, and the host GPU stack. Docker freezes that entire dependency graph, eliminating the endless “I need libssl1.1 on the server” back‑and‑forth.
Leveraging Containerized AI Workflows
Once inside a container you can compose auxiliary services (Redis, Postgres, or a VNC server) with Docker‑Compose, attach side‑car monitors, and spin up multiple agents for load‑balancing—all without touching the host OS.
**My take:** For any AI‑driven desktop automation, start with a container. The time you save on environment hell pays for itself within the first two deployment cycles.
Architecting Your Agent: Defining the Application Boundaries
Identifying Core Components (Agent Logic, GUI, Dependencies)
| Component | Responsibility | Runtime |
|---|---|---|
| **Agent Core** | Prompt engineering, tool‑use, Claude API calls | Python async worker |
| **GUI Frontend** | Text display, user input, cancel/confirm dialogs | PyQt6 (or Streamlit for web) |
| **State Store** | Session history, tool results, cache | Redis (docker‑service) |
| **Secret Provider** | API key injection | Docker secret or env‑var |
Keeping the GUI separate from the core logic lets you replace the visual layer (e.g., swap PyQt for Streamlit) without rewriting the agent.
State Management & Data Persistence Across Container Lifecycles
Claude’s *computer‑use* tool can spawn subprocesses that run for minutes. To survive a container restart you must:
- Write each tool‑call result immediately to Redis (`HSET session:
`). - Persist the **conversation history** as a JSON blob in a bind‑mounted file (`/data/sessions.json`).
- On startup, the agent reads the latest session ID from Redis and reconstructs the `messages` list.
API Key and Sensitive Configuration Handling
Never bake `ANTHROPIC_API_KEY` into the image. Two patterns work well in 2026:
- **Docker secrets** (ideal for Swarm or Kubernetes). Place the key in `/run/secrets/anthropic_key` and read it at runtime.
- **Runtime env‑var injection** via `docker compose run –env-file=.env`. The `.env` file is excluded from version control (`.gitignore`).
Both keep the secret out of layer caches, preventing accidental leakage.
Building Your Claude Agent: A Reference Implementation
Below is a minimal yet production‑grade agent that:
- Streams Claude’s SSE responses into a PyQt `QTextEdit` without blocking the UI.
- Uses a background worker thread that pushes events onto a thread‑safe `queue.Queue`.
- Persists session state in Redis.
- Handles rate‑limit back‑off with `tenacity`.
**Note:** If you prefer a web UI, the same core can be wrapped in a Streamlit app—see my post on [Creating a Dedicated Agent GUI with Streamlit and Claude Desktop (2026)](https://nileshblog.tech/?p=7205).
Project Layout
claude-agent/
├─ app/
│ ├─ __init__.py
│ ├─ core.py # Claude client + event loop
│ ├─ gui.py # PyQt6 UI
│ └─ state.py # Redis helper
├─ Dockerfile
├─ docker-compose.yml
├─ pyproject.toml
└─ README.md
`pyproject.toml` (Poetry)
# pyproject.toml – version 2024.1
[tool.poetry]
name = "claude-agent"
version = "0.1.0"
description = "Docker‑ready Claude 3.5 Sonnet desktop agent"
authors = ["Nilesh <nilesh@nileshblog.tech>"]
license = "MIT"
requires-python = ">=3.11"
[tool.poetry.dependencies]
python = "^3.11"
anthropic = "^1.8"
pyqt6 = "^6.6"
redis = "^5.0"
tenacity = "^9.0"
orjson = "^3.10"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
`app/state.py` – Redis session helper
# app/state.py – v1.0
import json
import redis
from typing import Dict, List
REDIS_URL = "redis://redis:6379/0"
class SessionStore:
def __init__(self):
self.client = redis.from_url(REDIS_URL, decode_responses=True)
def save_message(self, session_id: str, role: str, content: str) -> None:
key = f"session:{session_id}:messages"
self.client.rpush(key, json.dumps({"role": role, "content": content}))
def load_messages(self, session_id: str) -> List[Dict]:
key = f"session:{session_id}:messages"
raw = self.client.lrange(key, 0, -1)
return [json.loads(item) for item in raw]
def set_last_id(self, session_id: str) -> None:
self.client.set("session:current", session_id)
def get_last_id(self) -> str:
return self.client.get("session:current") or "default"
`app/core.py` – Claude client with SSE streaming
# app/core.py – v1.2
import os
import asyncio
import json
from queue import Queue, Empty
from threading import Thread
from typing import List, Dict
import anthropic
import tenacity
from .state import SessionStore
# Load API key securely
def get_api_key() -> str:
secret_path = "/run/secrets/anthropic_key"
if os.path.exists(secret_path):
with open(secret_path) as f:
return f.read().strip()
return os.getenv("ANTHROPIC_API_KEY", "")
client = anthropic.Anthropic(api_key=get_api_key())
store = SessionStore()
# Queue that the GUI thread consumes
event_queue: Queue = Queue(maxsize=100)
@tenacity.retry(
wait=tenacity.wait_exponential_jitter(min=1, max=10),
stop=tenacity.stop_after_attempt(5),
retry=tenacity.retry_if_exception_type(anthropic.RateLimitError),
)
async def stream_completion(messages: List[Dict]) -> None:
"""Yield chunks from Claude using SSE; push them to event_queue."""
response = client.messages.stream(
model="claude-3-5-sonnet-20241022",
max_tokens=1024,
temperature=0.7,
messages=messages,
)
for event in response:
# Each event is a dict with a "type" key ("content_block_start", "content_block_delta", etc.)
if event.type == "content_block_delta":
delta = event.delta.get("text", "")
event_queue.put_nowait(delta)
elif event.type == "message_stop":
# Signal end-of-stream
event_queue.put_nowait(None)
def worker_thread(session_id: str) -> None:
"""Background thread that keeps the async loop alive."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
messages = store.load_messages(session_id)
try:
loop.run_until_complete(stream_completion(messages))
except Exception as exc:
event_queue.put_nowait(f"\n[Error] {exc}")
finally:
loop.close()
def start_worker(session_id: str) -> Thread:
t = Thread(target=worker_thread, args=(session_id,), daemon=True)
t.start()
return t
`app/gui.py` – PyQt6 UI with non‑blocking streaming
# app/gui.py – v2.0
import sys
from pathlib import Path
from queue import Empty
from PyQt6.QtWidgets import QApplication, QWidget, QVBoxLayout, QTextEdit, QLineEdit, QPushButton
from PyQt6.QtCore import QTimer
from .core import start_worker, event_queue, store
class ClaudeWindow(QWidget):
def __init__(self, session_id: str):
super().__init__()
self.session_id = session_id
self.init_ui()
self.worker = start_worker(session_id)
# Poll the queue every 50 ms
self.timer = QTimer()
self.timer.timeout.connect(self.consume_events)
self.timer.start(50)
def init_ui(self):
self.setWindowTitle("Claude Desktop Agent")
self.resize(720, 560)
layout = QVBoxLayout()
self.log = QTextEdit()
self.log.setReadOnly(True)
self.input = QLineEdit()
self.send_btn = QPushButton("Send")
layout.addWidget(self.log)
layout.addWidget(self.input)
layout.addWidget(self.send_btn)
self.setLayout(layout)
self.send_btn.clicked.connect(self.handle_user_input)
self.input.returnPressed.connect(self.handle_user_input)
def handle_user_input(self):
user_msg = self.input.text().strip()
if not user_msg:
return
self.log.append(f"<b>You:</b> {user_msg}")
store.save_message(self.session_id, "user", user_msg)
self.input.clear()
# Restart worker with the new message list
self.worker = start_worker(self.session_id)
def consume_events(self):
try:
while True:
chunk = event_queue.get_nowait()
if chunk is None: # End of stream marker
self.log.append("<i>— response complete —</i>")
break
self.log.moveCursor(self.log.textCursor().End)
self.log.insertPlainText(chunk)
self.log.moveCursor(self.log.textCursor().End)
except Empty:
pass
def main():
app = QApplication(sys.argv)
session_id = store.get_last_id()
store.set_last_id(session_id) # Ensure a record exists
win = ClaudeWindow(session_id)
win.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
Running locally (no Docker)
# Install dependencies
poetry install
# Export your Anthropic key (or create a Docker secret later)
export ANTHROPIC_API_KEY=sk-ant-...
# Spin up Redis
docker run -d --name redis -p 6379:6379 redis:7-alpine
# Launch the app
poetry run python -m app.gui
You should see a sleek window where Claude’s replies appear character‑by‑character, while the input box remains responsive—thanks to the background thread and the queue.
Crafting the Perfect Dockerfile for AI Agents
Choosing the Right Base Image (Python Slim vs. Full)
- **`python:3.11-slim-bookworm`** – Small (≈120 MB), glibc‑based, works with most wheels.
- **`python:3.11-alpine`** – Even slimmer but often forces you to compile wheels against musl, leading to obscure errors in `numpy` or `torch`. **Avoid for AI workloads**.
Multi‑Stage Build for Optimized GUI and CLI Layers
We separate the heavy build‑time dependencies (compilers, headers) from the runtime layer:
# Dockerfile – v3.1
# ---------- Stage 1: Builder ----------
FROM python:3.11-slim-bookworm AS builder
ENV PYTHONUNBUFFERED=1
# System build deps
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential gcc libffi-dev libssl-dev git && \
rm -rf /var/lib/apt/lists/*
# Install Poetry for deterministic builds
RUN pip install --no-cache-dir poetry==1.8.2
WORKDIR /app
COPY pyproject.toml poetry.lock ./
RUN poetry export -f requirements.txt --output requirements.txt --without-hashes
# Install all deps into /install (includes compiled wheels)
RUN pip install --no-cache-dir -r requirements.txt --target /install
# ---------- Stage 2: Runtime ----------
FROM python:3.11-slim-bookworm AS runtime
ENV PYTHONUNBUFFERED=1
ENV PATH="/opt/venv/bin:$PATH"
# System runtime deps (X11 for GUI)
RUN apt-get update && apt-get install -y --no-install-recommends \
libqt6gui6 libqt6core6 libqt6widgets6 xauth && \
rm -rf /var/lib/apt/lists/*
# Copy compiled dependencies
COPY --from=builder /install /opt/venv
WORKDIR /app
COPY app ./app
COPY docker-compose.yml .
# Non‑root user for security
RUN groupadd -r app && useradd -r -g app app && \
chown -R app:app /app
USER app
# Healthcheck – ensure Claude client can ping the API
HEALTHCHECK --interval=30s --timeout=5s \
CMD python -c "import anthropic, os; \
client=anthropic.Anthropic(api_key=os.getenv('ANTHROPIC_API_KEY')); \
client.models.list()" || exit 1
CMD ["python", "-m", "app.gui"]
**Why this works:**
- The builder stage has `gcc` etc., which are stripped away in the final image, keeping it under 150 MB.
- All wheels land in `/opt/venv`, making the runtime read‑only—great for immutable containers.
- Adding `non‑root` user aligns with Docker security