AWS Builder Center
From Plain English to AWS Architecture Diagrams with Amazon Bedrock and diagram-as-code

From Plain English to AWS Architecture Diagrams with Amazon Bedrock and diagram-as-code

AWS Architecture Diagrams with Amazon Bedrock and diagram-as-code

Cloud Engineer
Architecture diagrams are essential, but drawing them by hand is slow. You align icons, fix arrows, and redo it all when the design changes. I wanted to describe an architecture in plain English — or paste a screenshot of an existing one — and get a clean, AWS-style diagram back.
So I built Diagram AI. It uses Amazon Bedrock (Claude Haiku 4.5) to understand the request and AWS Lambda running the open-source diagram-as-code  CLI to render the actual diagram. This post focuses on how the AWS pieces fit together — and the non-obvious gotchas I hit, in case they save you time.

Architecture

1
2
3
4
5
6
7
8
9
[User input: text or a diagram screenshot]
│
▼
[Frontend on Vercel · Next.js]
├─ /api/generate-yaml → Amazon Bedrock (Claude Haiku 4.5) → diagram-as-code YAML
└─ /api/render → AWS Lambda (Function URL) → awsdac CLI → PNG
│
▼
[Browser: live preview + PNG download]
The key design decision: I don't ask the LLM to "draw" anything. I ask it to emit YAML in the diagram-as-code schema, and a deterministic CLI renders that YAML into a real, convention-compliant AWS diagram. Bedrock does the understanding; a proven AWS tool does the drawing. This separation keeps output reliable — the model can't produce a malformed image, only structured text that either validates or doesn't.

Why Amazon Bedrock + Claude Haiku 4.5

Two reasons drove the model choice:
  • Cost and latency. Generating structured YAML is a well-bounded task. Haiku 4.5 is fast and inexpensive, and end-to-end generation lands in a few seconds.
  • Vision, included. Claude Haiku 4.5 on Bedrock is multimodal, which later let me add "read an existing diagram from a screenshot" with the same model and the same API call.
I used the cross-region inference profile in us-east-1:
1
us.anthropic.claude-haiku-4-5-20251001-v1:0
The Bedrock call itself is a standard InvokeModel with the Anthropic messages format. The system prompt teaches the model the diagram-as-code schema and layout rules; the user message carries the request (and optionally an image).
Gotcha — lazy-init your Bedrock client. I first constructed the BedrockRuntimeClient at module load. During the frontend's production build (which runs without AWS environment variables), that threw Region is missing. Moving client creation into a request-time factory fixed it and is a good pattern regardless of framework.

Rendering on AWS Lambda

diagram-as-code is a Go CLI (awsdac) that turns YAML into diagrams using official AWS icons. My first instinct was to import it as a Go library inside a serverless function — but almost all of its logic lives under Go's internal/ directory, which cannot be imported from outside the module. So I run it as a CLI in Lambda instead.
I also skipped container images (no Docker on my build machine) in favor of a zip-based Lambda on the Python 3.12 runtime:
  1. Download the official prebuilt awsdac Linux binary from the project's GitHub releases.
  2. Add a small Python handler that shells out to it.
  3. Zip both and deploy. No Docker, no Go toolchain.
The handler receives YAML, writes it to /tmp, runs the binary, and returns the PNG as base64:
1
2
3
4
5
proc = subprocess.run(
[AWSDAC_PATH, "--allow-untrusted-definitions", in_path, "-o", out_path],
capture_output=True, text=True,
env={**os.environ, "HOME": "/tmp"}, timeout=25,
)
Two things to note for Lambda specifically:
  • /var/task is read-only. Don't try to chmod the bundled binary at runtime; set the executable bit when you zip it instead (the Unix zip command preserves it).
  • awsdac caches fetched definition files under $HOME/.cache. On warm invocations that meant stale icon definitions. Clearing the cache at the start of each request (with HOME=/tmp) keeps results fresh.

The Function URL 403 that wasn't in my policy

I exposed the function via a Lambda Function URL with AuthType: NONE and a resource policy granting lambda:InvokeFunctionUrl to *. Direct aws lambda invoke worked. The Function URL returned 403 AccessDeniedException.
The config looked correct. No SCPs, no RCPs. The real cause was a platform change:
Since October 2025, public Lambda Function URLs require two permissions: lambda:InvokeFunctionUrl and lambda:InvokeFunction.
Adding the second statement fixed it immediately:
1
2
3
4
aws lambda add-permission --function-name diagram-ai-render \
--statement-id FunctionURLAllowInvoke \
--action lambda:InvokeFunction --principal "*" \
--invoked-via-function-url --region us-east-1
If a Lambda 403 makes no sense, check whether the platform's rules changed recently — not just your own config.

Teaching the model to draw clean diagrams

Valid YAML was easy. Good-looking diagrams took iteration, each round driven by an ugly result:
  1. Arrows piercing containers. The model drew User → ALB straight through the "AWS Cloud" and "VPC" boxes. Fix: require external→internal traffic to route through an Internet Gateway placed on the VPC border (BorderChildren) — exactly like AWS reference architectures.
  2. Arrows crossing label text. diagram-as-code pins labels under icons and routes arrows through icon centers, so vertical flows always crossed text. After testing four layouts, horizontal flow (Direction: horizontal) won: horizontal arrows pass at icon mid-height and clear the labels.
  3. Invented resource types. Asked for a "box," the model produced AWS::Diagram::Container, which doesn't exist. Fix: an explicit allowlist of safe AWS types plus a rule never to invent types.
Every fix went into the system prompt as a concrete good/bad example. The prompt got long; the diagrams got clean.

Hybrid diagrams: beyond AWS icons

diagram-as-code is AWS-first but extensible via definition files. To draw "GitHub → Vercel → AWS Lambda" properly, I generated 19 external-service icons (Vercel, Cloudflare, Supabase, OpenAI, Stripe, and more): pull SVGs from simple-icons, tint with brand colors, convert to PNG with sharp, and map them to types like External::Vercel in a definition file.
Note that awsdac rejects definition files from non-official URLs unless you pass --allow-untrusted-definitions. I serve the definition file and icon zip from the app's own origin so they're versioned with each deploy.

Reading existing diagrams

Because Claude Haiku 4.5 is multimodal, I extended the Bedrock call to accept an image, giving three modes for free:
  • Image only → reproduce an equivalent diagram.
  • Image + text → transform it ("swap Cloudflare for CloudFront, EC2 for ECS").
  • Text only → the original flow.
The same InvokeModel call carries an image content block plus a text instruction — no separate vision pipeline needed.

Takeaways

  • Let the LLM produce structured text, not pixels. Bedrock writes schema-valid YAML; a deterministic CLI renders it. Reliability comes from that split.
  • Read the source before choosing an integration path. Go's internal/ rule ruled out the library approach on day one — I just found out on day two.
  • When auth fails inexplicably, suspect recent platform changes. The dual-permission Function URL rule was invisible in my own config.
  • Lazy-init AWS SDK clients. It avoids build-time "Region is missing" and is cleaner anyway.

Stack

Amazon Bedrock (Claude Haiku 4.5, text + vision) · AWS Lambda (zip, Python 3.12) running the awsdac CLI via a Function URL · Next.js frontend on Vercel · simple-icons + sharp for non-AWS icons.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article