Skip to content

Sample app

The sample/ app deploys a MicroVM that runs a model worker on Amazon Bedrock (us-east-1) — Nova 2 Lite by default, Claude Opus 4.8 opt-in. It is the target of the E2E suite (make e2e), which boots one VM, runs two tests against it, and terminates it in teardown:

  • echo — a deterministic round-trip (build → run → auth → ingress → app), no model in the loop. This is the library's gate.
  • bedrock — a real model round-trip: it asks the model to add two random numbers and asserts the reply contains their sum (non-deterministic; on failure the captured error body is surfaced).
make deploy   # npx aws-cdk deploy the sample
make e2e      # launch the MicroVM, prompt, assert, terminate in teardown
make destroy  # tear down

Choosing the model: Nova vs Opus

The /bedrock tier calls one model, selected by the MODEL_PROVIDER / MODEL_ID env vars. The deterministic /echo tier calls no model — it's the gate that never depends on a model (the /bedrock E2E test does, and asserts the model's answer).

Default — Amazon Nova 2 Lite Opt-in — Claude Opus 4.8
MODEL_PROVIDER bedrock anthropic
MODEL_ID us.amazon.nova-2-lite-v1:0 us.anthropic.claude-opus-4-8
SDK / API boto3 Bedrock Converse (model-agnostic) Anthropic SDK AnthropicBedrockMantle (Messages)
Prerequisite works out of the box needs Bedrock model access enabled for Opus
Why Nova 2 Lite is inference-profile-only, so the us. profile id is used the Mantle Messages endpoint, not classic InvokeModel

How to switch. Set the two env vars — either on the construct (environment={"MODEL_PROVIDER": "anthropic", "MODEL_ID": "us.anthropic.claude-opus-4-8"}) or in the Dockerfile ENV — and redeploy. Env vars are baked into the snapshot, so this rebuilds the image to a new version; launch a fresh VM to pick it up (a running VM keeps the env it booted with). No IAM change is needed — the sample's VM execution role already grants both providers (bedrock:InvokeModel on the Nova inference-profile + foundation-model ARNs, and bedrock-mantle:CreateInference on the account's default project), so flipping the provider just works.

The image (Dockerfile)

Everything is installed at build time and snapshotted, so no ALL OS capability is needed at runtime. boto3 drives the default Nova/Converse path; anthropic drives the opt-in Opus path. Model routing is set via non-secret env vars (AWS_REGION is injected by the runtime — never set it).

# Sample MicroVM app image — tiers 1 (echo) + 2 (a model call on Bedrock).
# Layered on the AWS-managed al2023-1 base image, proven end-to-end
# (build -> run -> auth -> ingress -> /echo).
FROM public.ecr.aws/docker/library/python:3.14-slim

# Everything is installed at BUILD time (snapshotted) — no ALL os-capability needed at runtime.
# boto3 drives the default Nova/Converse path; anthropic drives the opt-in Opus path.
# --no-cache-dir keeps the snapshot small.
RUN pip install --no-cache-dir boto3 anthropic

# --- Model routing for the /bedrock worker tier (non-secret) -----------------------------------
# Default: Amazon Nova 2 Lite via the boto3 Bedrock Converse API (INFERENCE_PROFILE-only; the
# us. profile is ACTIVE in us-east-1). To use Claude Opus 4.8 via the Anthropic SDK instead
# (needs Bedrock model access), override at build/run time:
#     MODEL_PROVIDER=anthropic  MODEL_ID=us.anthropic.claude-opus-4-8
# NOTE: AWS_REGION must NOT be set here — the MicroVM runtime injects it (reserved key).
ENV MODEL_PROVIDER=bedrock \
    MODEL_ID=us.amazon.nova-2-lite-v1:0

# --- Opt-in tier 3 (Claude Code headless CLI, Claude-only; harmless if the CLI isn't installed) ---
# ANTHROPIC_API_KEY must stay UNSET so the CLI routes to Bedrock, not the Anthropic API.
ENV CLAUDE_CODE_USE_BEDROCK=1 \
    ANTHROPIC_MODEL=us.anthropic.claude-opus-4-8 \
    ANTHROPIC_SMALL_FAST_MODEL=us.anthropic.claude-opus-4-8 \
    CLAUDE_CODE_MAX_OUTPUT_TOKENS=4096 \
    DISABLE_AUTOUPDATER=1 \
    DISABLE_TELEMETRY=1 \
    DISABLE_ERROR_REPORTING=1 \
    IS_SANDBOX=1 \
    HOME=/workspace
RUN mkdir -p /workspace

WORKDIR /app
COPY worker/worker.py /app/worker.py

# --- Tier 3 (opt-in, NOT installed by default): Claude Code headless agent ---
# Uncomment to layer the CLI agent on top (Node 22 + the CLI; the ENV block above already
# configures it for Bedrock — IS_SANDBOX=1 allows --dangerously-skip-permissions in the VM):
#   RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \
#       && npm install -g @anthropic-ai/claude-code \
#       && apt-get clean && rm -rf /var/lib/apt/lists/*

EXPOSE 8080
CMD ["python", "/app/worker.py"]

The worker (worker.py)

An HTTP server on :8080 with three isolated tiers: /echo (deterministic — the E2E gate), /bedrock (one model call — the E2E model test asks for the sum of two random numbers and checks it), and an opt-in agent tier. It also exposes the runtime lifecycle hooks under /aws/lambda-microvms/runtime/v1/* and /health. Secrets are never read from image env — per-VM data arrives via the /run hook payload or SSM at runtime.

worker.py (full source)
"""Tiered MicroVM worker — HTTP server on :8080.

Tiers (isolated so the risky pieces never gate the library):
  1. ``/echo``    — deterministic, no model. **E2E asserts on this.**
  2. ``/bedrock`` — one model call. Default: Amazon Nova 2 Lite via the boto3 Bedrock Converse API;
                    opt-in Claude Opus 4.8 via the Anthropic SDK (``MODEL_PROVIDER=anthropic``).
                    Credentials come from the VM execution role; errors captured verbatim for diagnosis.
  3. ``agent``    — Claude Code headless; opt-in, NOT installed by default (see Dockerfile).

Also exposes the runtime lifecycle hooks under ``/aws/lambda-microvms/runtime/v1/*``
(the sample runs with hooks disabled, so these are inert but demonstrate the contract)
and ``/health`` for smoke checks. Secrets are NEVER read from image env — per-VM data
arrives via the ``/run`` hook payload (runHookPayload) or SSM at runtime.
"""

from __future__ import annotations

import json
import logging
import os
import traceback
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import Any

PORT = int(os.environ.get('PORT', '8080'))
# Model routing for the /bedrock tier (set in the Dockerfile). Default: Amazon Nova 2 Lite via the
# boto3 Bedrock Converse API. Set MODEL_PROVIDER=anthropic + MODEL_ID=us.anthropic.claude-opus-4-8
# to call Claude Opus 4.8 via the Anthropic SDK instead (needs Bedrock model access).
MODEL_PROVIDER = os.environ.get('MODEL_PROVIDER', 'bedrock')
MODEL_ID = os.environ.get('MODEL_ID', 'us.amazon.nova-2-lite-v1:0')
MAX_TOKENS = int(os.environ.get('MAX_TOKENS', '1024'))
AWS_REGION = os.environ.get('AWS_REGION', 'us-east-1')  # runtime-injected (reserved key)
HOOK_PREFIX = '/aws/lambda-microvms/runtime/v1/'

logging.basicConfig(level=os.environ.get('LOG_LEVEL', 'INFO').upper(), format='%(asctime)s %(levelname)s %(message)s')
logger = logging.getLogger('claude-agent')


def echo_transform(prompt: str) -> str:
    """Deterministic, model-free transform — the stable target for E2E assertions."""
    return f'ECHO[{prompt}]'


def model_completion(prompt: str) -> str:
    """Tier 2 — one model call; region + credentials come from the VM execution role at runtime.

    Default provider ``bedrock`` calls Amazon Nova via the model-agnostic Bedrock Converse API;
    ``anthropic`` calls Claude via the Anthropic SDK (opt-in — needs Bedrock model access).
    """
    if MODEL_PROVIDER == 'anthropic':
        return _opus_completion(prompt)
    return _nova_completion(prompt)


def _nova_completion(prompt: str) -> str:
    """Amazon Bedrock Converse API (boto3) — model-agnostic; the default for any Bedrock model."""
    import boto3  # imported lazily so tier 1 never depends on it

    client = boto3.client('bedrock-runtime', region_name=AWS_REGION)
    response = client.converse(
        modelId=MODEL_ID,
        messages=[{'role': 'user', 'content': [{'text': prompt}]}],
        inferenceConfig={'maxTokens': MAX_TOKENS},
    )
    return ''.join(block['text'] for block in response['output']['message']['content'] if 'text' in block)


def _opus_completion(prompt: str) -> str:
    """Anthropic SDK on Bedrock (AnthropicBedrockMantle) — Claude only; opt-in via MODEL_PROVIDER=anthropic."""
    from anthropic import AnthropicBedrockMantle  # imported lazily so the default path never needs anthropic

    client = AnthropicBedrockMantle(aws_region=AWS_REGION)
    message = client.messages.create(model=MODEL_ID, max_tokens=MAX_TOKENS, messages=[{'role': 'user', 'content': prompt}])
    return ''.join(block.text for block in message.content if block.type == 'text')


class WorkerHandler(BaseHTTPRequestHandler):
    server_version = 'microvm-worker/1.0'

    def _send_json(self, status: HTTPStatus, body: dict[str, Any]) -> None:
        payload = json.dumps(body).encode()
        self.send_response(status)
        self.send_header('Content-Type', 'application/json')
        self.send_header('Content-Length', str(len(payload)))
        self.end_headers()
        self.wfile.write(payload)

    def _read_json(self) -> dict[str, Any]:
        length = int(self.headers.get('Content-Length', '0'))
        if length == 0:
            return {}
        parsed = json.loads(self.rfile.read(length).decode())
        return parsed if isinstance(parsed, dict) else {}

    def do_GET(self) -> None:  # noqa: N802 — BaseHTTPRequestHandler contract
        logger.info('GET %s', self.path)
        if self.path == '/health':
            self._send_json(HTTPStatus.OK, {'status': 'ok'})
        else:
            self._send_json(HTTPStatus.NOT_FOUND, {'error': f'unknown path {self.path}'})

    def do_POST(self) -> None:  # noqa: N802 — BaseHTTPRequestHandler contract
        logger.info('POST %s', self.path)
        if self.path.startswith(HOOK_PREFIX):
            self._handle_hook(self.path.removeprefix(HOOK_PREFIX))
            return
        try:
            body = self._read_json()
        except (json.JSONDecodeError, UnicodeDecodeError) as exc:
            logger.warning('POST %s — invalid JSON body: %s', self.path, exc)
            self._send_json(HTTPStatus.BAD_REQUEST, {'error': f'invalid JSON body: {exc}'})
            return
        prompt = str(body.get('prompt', ''))
        logger.info('POST %s — prompt=%r', self.path, prompt)
        if self.path == '/echo':
            result = echo_transform(prompt)
            logger.info('echo — result=%r', result)
            self._send_json(HTTPStatus.OK, {'mode': 'echo', 'result': result})
        elif self.path == '/bedrock':
            self._handle_bedrock(prompt)
        else:
            logger.info('POST %s — unknown path', self.path)
            self._send_json(HTTPStatus.NOT_FOUND, {'error': f'unknown path {self.path}'})

    def _handle_hook(self, hook: str) -> None:
        """Lifecycle hooks: /run admits traffic when it returns HTTP 200; per-VM secrets/refs arrive in its payload.

        UUIDs/secrets must be generated HERE with a CSPRNG (never at build — snapshots are shared).
        """
        logger.info('lifecycle hook invoked: %s', hook)
        if hook in ('run', 'resume', 'suspend', 'terminate'):
            self._send_json(HTTPStatus.OK, {'hook': hook, 'status': 'ok'})
        else:
            self._send_json(HTTPStatus.NOT_FOUND, {'error': f'unknown hook {hook}'})

    def _handle_bedrock(self, prompt: str) -> None:
        logger.info('bedrock — provider=%s model=%s prompt=%r', MODEL_PROVIDER, MODEL_ID, prompt)
        try:
            result = model_completion(prompt)
            logger.info('bedrock — provider=%s model=%s result=%r', MODEL_PROVIDER, MODEL_ID, result)
            self._send_json(HTTPStatus.OK, {'mode': 'bedrock', 'provider': MODEL_PROVIDER, 'model': MODEL_ID, 'result': result})
        except Exception as exc:  # capture the WHOLE error body — a bare 500 is undiagnosable without it
            logger.exception('bedrock tier failed (provider=%s model=%s)', MODEL_PROVIDER, MODEL_ID)
            self._send_json(
                HTTPStatus.INTERNAL_SERVER_ERROR,
                {
                    'mode': 'bedrock',
                    'provider': MODEL_PROVIDER,
                    'model': MODEL_ID,
                    'error': f'{exc.__class__.__name__}: {exc}',
                    'trace': traceback.format_exc(),
                },
            )

    def log_message(self, format: str, *args: Any) -> None:  # noqa: A002 — BaseHTTPRequestHandler contract
        logger.info('%s %s', self.address_string(), format % args)


def main() -> None:
    logger.info('worker listening on :%d (model=%s)', PORT, MODEL_ID)
    ThreadingHTTPServer(('0.0.0.0', PORT), WorkerHandler).serve_forever()


if __name__ == '__main__':
    main()