{"slug": "building-production-ai-agents-on-aws-bedrock-architecture-and-code-decisions-in", "title": "Building Production AI Agents on AWS Bedrock — Architecture and Code Decisions Worth Keeping in Mind", "summary": "A developer built a serverless rental management bot on AWS Bedrock that uses Claude as an AI agent to analyze payments, debts, and trends from natural-language messages. The agent integrates with DynamoDB for conversation history and uses IAM authentication instead of API keys. Key architectural decisions include cross-region inference for lower latency and injecting current state into the system prompt rather than relying on conversation history.", "body_md": "Models are stateless. They process one request, produce a text result, and forget. They don't take actions, don't integrate by default with your data, or coordinate multi-step workflows on their own. Agents solve this by wrapping a model in a runtime system that gives it tools, memory, and a reasoning loop.\n\nThis application is a serverless rental management bot — landlords send natural-language messages and receive notifications through Telegram and email. The best part is that analysis of current payments, debts, and trends is done by an agent powered by Claude. It gives you insights about your data, it remembers your preferences, it's a real collaborator. That's what every agent can do. A chatbot is just text with no deep integrations with your data or systems — by definition, less capable.\n\nCode on GitHub: [https://github.com/jorgetovar/whatsapp-rental-manager](https://github.com/jorgetovar/whatsapp-rental-manager)\n\nAgents are easy to build but not simple. We should keep in mind everything we've always done in backend systems and production applications: security, integrations, cohesion, coupling, data management, scalability, reliability, costs, operational excellence, latency and performance — all the non-functional requirements still apply.\n\nThe operational answer: IAM authentication. There's no API key to rotate, leak, or store in Secrets Manager. The same boto3 session pattern you use for DynamoDB works for Bedrock. Cross-region inference (`us.anthropic.claude-sonnet-4-6`\n\n) routes to the lowest-latency US region automatically and gives higher throughput limits than a single-region endpoint.\n\n``` php\ndef create_agent(model_id: str = None, extra_context: str = \"\") -> Agent:\n    model_id = model_id or os.environ.get(\"BEDROCK_MODEL_ID\", \"us.anthropic.claude-sonnet-4-6\")\n    boto_session = boto3.Session(\n        profile_name=os.environ.get(\"AWS_PROFILE\"),\n        region_name=os.environ.get(\"AWS_REGION_NAME\", \"us-east-1\"),\n    )\n    model = BedrockModel(model_id=model_id, boto_session=boto_session)\n    return Agent(model=model, system_prompt=SYSTEM_PROMPT + extra_context, tools=[...])\n```\n\nLambda is stateless. Every invocation starts cold. To give Claude memory of the conversation, history must be persisted externally and rehydrated on each request.\n\nI'm using DynamoDB to keep track of messages, but there are better options — AWS Strands has built-in session handling that can summarize history or keep an exact number of messages, which is what we're doing right now.\n\n```\n_MAX_MESSAGES = 20    # ~10 turns — enough context, safe DynamoDB item size\n_TTL_SECONDS = 86400  # 24h — conversations reset overnight\n\ndef save_history(chat_id: str, messages: list) -> None:\n    trimmed = _sanitize_messages(_strip_metadata(messages)[-_MAX_MESSAGES:])\n    get_table(\"conversations\").put_item(Item={\n        \"chatId\": str(chat_id),\n        \"messages\": json.dumps(trimmed),\n        \"ttl\": int(time.time()) + _TTL_SECONDS,\n    })\n```\n\nDynamoDB TTL does the cleanup automatically — no cron job, no cost.\n\nThere are two ways to give the model current state: inject it into the system prompt per request, or let it accumulate in conversation history.\n\nConversation history is unreliable for state. It gets trimmed. It contains noise. The model's attention decays over long contexts. If the landlord added a new property three turns ago, you can't guarantee the model remembers it now.\n\nThe pattern that works:\n\n``` php\ndef _build_user_context(caller: dict) -> str:\n    landlord = get_table(\"landlords\").get_item(Key={\"landlordId\": landlord_id}).get(\"Item\")\n    active = sum(1 for l in leases if l.get(\"status\") == \"active\")\n    return (\n        \"\\n\\nESTADO DEL USUARIO (fijado por el sistema):\\n\"\n        f\"- Rol: ARRENDADOR — {name}\\n\"\n        f\"- Inmuebles activos: {active}\\n\"\n        f\"- Ya aceptó política de datos. NO repitas bienvenida.\"\n    )\n\nagent = create_agent(extra_context=user_ctx)\nagent.messages = load_history(chat_id)\nresponse = agent(text)\n```\n\nWith the Strands SDK, tools are Python functions decorated with `@tool`\n\n. The docstring is not documentation — it's the API contract with the model. Be explicit. This steers the agent in the right direction and produces reliable tool calls.\n\n``` python\n@tool\ndef log_payment(\n    tenant_name_or_phone: str,\n    amount: int,\n    period: str = \"\",\n    method: str = \"other\",\n) -> str:\n    \"\"\"Registra un pago recibido de un inquilino.\n    Ej: tenant_name_or_phone='Juan', amount=800000.\n    period en YYYY-MM (vacío = mes actual).\n    method: nequi/daviplata/efectivo/other\n    \"\"\"\n    ...\n```\n\nWhen the user says \"Juan pagó 800k\", Claude infers `log_payment(tenant_name_or_phone=\"Juan\", amount=800000)`\n\nbecause the docstring is explicit about what each parameter means. Vague docstrings produce wrong tool calls.\n\nThe most dangerous pattern in agent design: letting the model decide who the user is or what they're allowed to do.\n\nIn this system, identity comes from the phone number, which Telegram provides on every webhook. Role is resolved from DynamoDB by phone — never from model input or output.\n\n``` php\ndef resolve_caller_sync(phone: str) -> list[CallerContext]:\n    contexts: list[CallerContext] = []\n\n    # Check if registered landlord\n    resp = landlords_table.get_item(Key={\"landlordId\": phone})\n    if resp.get(\"Item\"):\n        contexts.append(CallerContext(role=\"landlord\", landlordId=phone))\n\n    # Check if active renter\n    for item in paginated_query(leases_table, IndexName=\"tenantPhone-index\", ...):\n        if item.get(\"status\") == \"active\":\n            contexts.append(CallerContext(role=\"renter\", landlordId=item[\"landlordId\"], ...))\n\n    return contexts\n```\n\nIt's the same principle as a traditional backend application where a JWT token travels in every request and you extract the claims to get the `customer_id`\n\n— you never accept it from the request body, because that would be a spoofing vulnerability.\n\nWebhook requests are also verified with a timing-safe HMAC check before any processing happens:\n\n``` php\ndef _verify_signature(secret: str, header: str) -> bool:\n    return hmac.compare_digest(secret, header or \"\")\n```\n\n`hmac.compare_digest`\n\ntakes constant time regardless of where the strings differ — an attacker can't probe the secret byte-by-byte by measuring response latency.\n\nIndividual Lambdas get exactly the permissions they need — the late-tenant reminder reads leases and tenants; it can't write. Deployments are a single command and that's it. Anyone can replicate this application easily.\n\n```\nPolicies:\n  - DynamoDBReadPolicy:       # read-only for reminder Lambdas\n      TableName: !Ref LeasesTable\n  - DynamoDBCrudPolicy:       # full CRUD for the webhook Lambda\n      TableName: !Ref PaymentsTable\n  - !Ref SesPolicy            # shared managed policy across all reminder functions\n```\n\nSecrets Manager dynamic references resolve at deploy time:\n\n```\nTELEGRAM_BOT_TOKEN: !Sub '{{resolve:secretsmanager:miarriendobot/telegram:SecretString:token}}'\n```\n\nNote: the secret value is baked into the Lambda environment variable at deploy time. Rotating the secret in Secrets Manager does not update the Lambda automatically — you must redeploy or update the environment variable directly.\n\nAgents are not deterministic. Now more than ever it's important to understand what's happening in the system — what the integration points are, whether something is failing, how to get notified. Having alarms, tracing, and good logs is always paramount.\n\nOne non-obvious gap: the webhook Lambda must always return HTTP 200 — Telegram retries delivery indefinitely on anything else. The side effect is that API Gateway always looks healthy even when every invocation is throwing an exception. Standard uptime monitoring is completely blind here.\n\n```\nWebhookErrorAlarm:\n  Type: AWS::CloudWatch::Alarm\n  Properties:\n    MetricName: Errors\n    Namespace: AWS/Lambda\n    Dimensions:\n      - Name: FunctionName\n        Value: !Ref WebhookFunction\n    Statistic: Sum\n    Period: 300\n    EvaluationPeriods: 1\n    Threshold: 3\n    ComparisonOperator: GreaterThanOrEqualToThreshold\n    AlarmActions:\n      - !Ref AlertTopic\n```\n\nA CloudWatch alarm on the Lambda `Errors`\n\nmetric is the only real signal. Without it, you find out the bot is broken from a user complaint, not a page.\n\nThe denormalization decision is a real trade-off. Storing `canon`\n\ndirectly on the `Lease`\n\neven though `Property`\n\nalso has it means reminder Lambdas fetch one item and have everything — no second read, no join.\n\n```\nclass Lease(BaseModel):\n    leaseId: str\n    landlordId: str\n    propertyId: str\n    tenantPhone: str\n    canon: int      # duplicated from Property — intentional: reminders need one read\n    dueDay: int\n    startDate: str\n    status: str = \"active\"\n```\n\nThe explicit cost: `update_canon`\n\nmust write both tables. DynamoDB is not relational — design for your read patterns, not for normalization.\n\nAny job gated on a calendar date is untestable in CI without a bypass parameter. `job_monthly_summary(force=True)`\n\nruns immediately regardless of the date. Design for testability from day zero.\n\n``` python\ndef job_late_tenant_reminder(force: bool = False):\n    today = datetime.now(timezone.utc)\n    for lease in _get_all_active_leases():\n        reminder_date = _due_date_this_month(due_day) + timedelta(days=5)\n        if not force and today.date() != reminder_date.date():\n            continue  # skip — not the right day\n        # send reminder...\n```\n\nThe same function runs locally via APScheduler and in production via EventBridge — no mocking, full parity.\n\n```\nboto3.Session(profile_name=os.environ.get(\"AWS_PROFILE\"))\n```\n\nThis single line works in both local development and Lambda without any branching. The key is `os.environ.get(\"AWS_PROFILE\")`\n\n— when `AWS_PROFILE`\n\nis not set in Lambda, `.get()`\n\nreturns `None`\n\n, and `boto3.Session(profile_name=None)`\n\ncorrectly falls back to the IAM execution role via IMDS.\n\nTwo mistakes to avoid:\n\n`os.environ.get(\"AWS_PROFILE\", \"\")`\n\nreturns `\"\"`\n\ninstead of `None`\n\n— boto3 then tries to find a profile named `\"\"`\n\nand fails silently.`AWS_PROFILE`\n\nas a Lambda environment variable — boto3 will look for a credentials file that doesn't exist in Lambda's runtime.Locally, your `.env`\n\nsets `AWS_PROFILE=default-c1`\n\nand the named profile is used. In Lambda, the variable is absent and the execution role takes over automatically. No `if local else lambda`\n\nbranching, no hardcoded profile names, no credentials in code.\n\nTools and good context make agents powerful. We pass just enough — but important — signals that allow for a coherent conversation. The model doesn't need everything; it needs the right things at the right time.\n\n```\n- Rol: ARRENDADOR — Jorge Tovar\n- Inmuebles activos: 2\n- Ya aceptó política de datos. NO repitas bienvenida.\n```\n\nThe model knows the user's role, their current state, and what it should not ask for. That's the entire context it needs to behave correctly.\n\nAlways cut at complete exchange boundaries — never in the middle of a `toolUse`\n\n/`toolResult`\n\npair. We shipped without this and Bedrock raised a `ValidationException`\n\nat turn 10. The fix walks the trimmed list and drops any incomplete exchange from the tail:\n\n``` php\ndef _sanitize_messages(messages: list) -> list:\n    # Drop leading user messages that only contain toolResult blocks\n    # (their corresponding assistant toolUse was trimmed away)\n    while start < len(messages):\n        msg = messages[start]\n        has_text = any(isinstance(b, dict) and \"text\" in b for b in msg.get(\"content\", []))\n        if msg.get(\"role\") != \"user\" or has_text:\n            break\n        start += 1\n\n    # Drop any assistant toolUse not matched by the next message's toolResult\n    if tool_use_ids and not tool_use_ids.issubset(result_ids):\n        break  # missing toolResult — drop from here\n```\n\nThe end of a complete reasoning loop is the only safe trim point.\n\nThe model shouldn't be responsible for things that can be validated deterministically in code. Request-scoped context is set once at the start of every invocation and propagated automatically to every tool — no threading required, safe because Lambda processes one request at a time per execution environment.\n\n```\n# lambda_handler.py — set before every agent call\nCURRENT_CONTEXT.clear()\nCURRENT_CONTEXT.update(caller.model_dump())\n\n# any tool — reads from the same dict\ndef _landlord_id() -> str:\n    return CURRENT_CONTEXT.get(\"landlordId\", \"\")\n```\n\nThe model receives that string, understands it's a business rule, and responds to the user naturally — \"Sorry, that feature is only available for landlords.\"\n\nMake it explicit whether an error is retryable or a business rule violation. The model handles them differently, and so does the user.\n\nBuilding agents on AWS Bedrock is not fundamentally different from building any production backend system — the non-functional requirements don't disappear because there's a language model in the middle. Security, observability, testability, and data integrity matter just as much, and in some cases more, because agent behavior is harder to predict than deterministic code.\n\nThe code is open source — if you're building something similar, the architecture is yours to take and adapt.\n\n**There has never been a better time to be an engineer and create value in society through software.**\n\nIf you enjoyed the articles, visit my blog at [jorgetovar.dev](https://jorgetovar.dev).", "url": "https://wpnews.pro/news/building-production-ai-agents-on-aws-bedrock-architecture-and-code-decisions-in", "canonical_source": "https://dev.to/aws-builders/building-production-ai-agents-on-aws-bedrock-architecture-and-code-decisions-worth-keeping-in-37jh", "published_at": "2026-07-10 16:13:33+00:00", "updated_at": "2026-07-10 16:43:20.676068+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-agents", "ai-infrastructure", "developer-tools"], "entities": ["AWS Bedrock", "Claude", "DynamoDB", "Lambda", "IAM", "GitHub", "Jorge Tovar"], "alternates": {"html": "https://wpnews.pro/news/building-production-ai-agents-on-aws-bedrock-architecture-and-code-decisions-in", "markdown": "https://wpnews.pro/news/building-production-ai-agents-on-aws-bedrock-architecture-and-code-decisions-in.md", "text": "https://wpnews.pro/news/building-production-ai-agents-on-aws-bedrock-architecture-and-code-decisions-in.txt", "jsonld": "https://wpnews.pro/news/building-production-ai-agents-on-aws-bedrock-architecture-and-code-decisions-in.jsonld"}}