The new Well‑Architected Agent turns a months‑long manual review into a quick AI‑driven conversation. In just a few API calls you can get actionable recommendations for your CDK or Terraform stacks, and even see security gaps highlighted by GuardDuty’s findings. This post shows exactly how to wire it up in a Node.js 22 project.
What the Well‑Architected Agent Is and How It Uses Generative AI
Why it matters – A typical Well‑Architected review requires a human expert to read your CloudFormation, CDK, or Terraform files, compare them against the five pillars (Operational Excellence, Security, Reliability, Performance Efficiency, Cost Optimization), and then write a report. That process can take weeks for a large organization. The Well‑Architected Agent replaces the human’s “eyes and brain” with a generative‑AI model that can understand code, ask clarifying questions, and produce a structured set of recommendations.
- Well‑Architected Agent – a managed AWS service that accepts a workload definition (a JSON description of your infrastructure) and returns a review result that looks like a normal Well‑Architected Review but is created by an LLM (large language model).
- Generative AI – an AI system that creates new content (text, code, images). In this case the model generates natural‑language advice based on the patterns it learned from millions of past reviews.
In plain English: Think of the Agent as a very knowledgeable colleague who can read your IaC (Infrastructure‑as‑Code) files, ask you a few follow‑up questions, and hand you a checklist of things to fix, all within minutes.
Minimal code to see the service metadata
// This tiny snippet shows how to ask the Well‑Architected service for its API version.
// It helps you verify that your SDK is talking to the right endpoint (us-east-1).
import { WellArchitectedClient, ListWorkloadsCommand } from "@aws-sdk/client-wellarchitected";
const client = new WellArchitectedClient({ region: "us-east-1" });
async function showServiceInfo() {
// ListWorkloadsCommand does not need any parameters; it just returns a list.
const resp = await client.send(new ListWorkloadsCommand({}));
console.log("Available workloads (if any):", resp.WorkloadSummaries?.length ?? 0);
}
showServiceInfo().catch(console.error);
Comments:
- WellArchitectedClient – the object that knows how to talk to the Well‑Architected API.
- region – the AWS region; the Agent preview only lives in us-east-1.
- ListWorkloadsCommand – a pre‑built request that the SDK turns into an HTTP call.
Enabling the Preview and Setting Up IAM Permissions
Why it matters – The Agent is still in preview, so AWS hides it behind a feature flag (wellarchitected:EnableAgentPreview) and restricts it to a single region. If you forget either step, the service returns 403 Forbidden, which looks like a generic permission error but is actually a configuration problem.
Step‑by‑step enablement
- Turn on the preview flag in the AWS Management Console → Well‑Architected → Settings → “Enable Agent preview”.
- Create an IAM policy that grants the preview permission and the basic Well‑Architected actions.
- Attach the policy to the role or user that your Node.js script will assume.
Example IAM policy (JSON)
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowWellArchitectedAgentPreview",
"Effect": "Allow",
"Action": [
"wellarchitected:EnableAgentPreview",
"wellarchitected:CreateWorkload",
"wellarchitected:GetWorkload",
"wellarchitected:ListWorkloads",
"wellarchitected:CreateWorkloadShare",
"wellarchitected:UpdateWorkload"
],
"Resource": "*"
},
{
"Sid": "AllowGuardDutyRead",
"Effect": "Allow",
"Action": [
"guardduty:ListFindings",
"guardduty:GetFindings"
],
"Resource": "*"
}
]
}
Tip: Keep the policy narrow (specific resources) in production; the example uses
"*"only for simplicity in a learning environment.
Verifying the permission
import {
WellArchitectedClient,
ListWorkloadsCommand,
} from "@aws-sdk/client-wellarchitected";
async function checkPermission() {
const client = new WellArchitectedClient({ region: "us-east-1" });
try {
await client.send(new ListWorkloadsCommand({}));
console.log("✅ Permission works – you can call the Well‑Architected API.");
} catch (err: any) {
if (err.name === "AccessDeniedException") {
console.error("❌ 403 – missing wellarchitected:EnableAgentPreview or region mismatch.");
} else {
console.error("Unexpected error:", err);
}
}
}
checkPermission().catch(console.error);
Comments:
- The
try/catchdistinguishes a true permission issue (403) from network or SDK problems. - Running this before any Agent call saves you a few minutes of debugging.
Calling the Agent from Node.js 22 with Native fetch
Why it matters – The preview endpoint is not yet wrapped in a high‑level SDK method, so you have to build the HTTP request yourself. Node.js 22 ships with a built‑in fetch API, so you don’t need any extra library like axios.
Preparing the workload definition
The Agent expects a JSON payload that describes the IaC you want reviewed. The easiest way is to feed it a CDK synth output (the cdk.out folder) or a Terraform plan JSON. For this example we’ll hand‑craft a tiny CDK‑like structure.
// workloadDefinition.ts
export const workloadDefinition = {
// A human‑readable name for the review.
WorkloadName: "MySampleApp",
// The pillar scores can be omitted; the Agent will fill them.
PillarPriorities: ["Security", "Reliability", "PerformanceEfficiency"],
// A list of resources extracted from your IaC. Each entry is a simplified view.
// In a real project you would generate this automatically from `cdk synth` or `terraform show -json`.
Resources: [
{
Type: "AWS::S3::Bucket",
LogicalId: "AssetsBucket",
Properties: {
VersioningConfiguration: { Status: "Enabled" },
PublicAccessBlockConfiguration: { BlockPublicAcls: true }
}
},
{
Type: "AWS::Lambda::Function",
LogicalId: "ProcessorFunction",
Properties: {
Runtime: "nodejs20.x",
Handler: "index.handler",
Timeout: 30
}
}
]
};
The fetch call
import { workloadDefinition } from "./workloadDefinition.js";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
// Helper: put a temporary JSON file in S3 so the Agent can fetch it.
// The preview endpoint only accepts a URL to a JSON document.
async function uploadPayloadToS3(payload: object, bucket: string, key: string) {
const s3 = new S3Client({ region: "us-east-1" });
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: JSON.stringify(payload),
ContentType: "application/json",
});
await s3.send(command);
// Generate a signed URL that lasts 10 minutes.
return getSignedUrl(s3, new PutObjectCommand({ Bucket: bucket, Key: key }), {
expiresIn: 600,
});
}
async function callWellArchitectedAgent() {
const payloadUrl = await uploadPayloadToS3(workloadDefinition, "my-preview-bucket", "payload.json");
// The preview endpoint (still in preview) lives at this fixed path.
const endpoint = "https://wellarchitected.us-east-1.amazonaws.com/preview/workload";
// Build the POST request body.
const body = JSON.stringify({
// The Agent needs a URL it can download the JSON from.
WorkloadDefinitionUrl: payloadUrl,
// Optional: a short description that the AI can reference.
Description: "Review of a simple S3 + Lambda app."
});
// Native fetch with IAM‑based signing (SigV4) is done via the @aws-sdk/signature-v4 package.
// For brevity we use a pre‑signed request approach: the SDK can create a signed URL for us.
const { SignatureV4 } = await import("@aws-sdk/signature-v4");
const { HttpRequest } = await import("@aws-sdk/protocol-http");
const { defaultProvider } = await import("@aws-sdk/credential-provider-node");
const signer = new SignatureV4({
credentials: defaultProvider(),
region: "us-east-1",
service: "wellarchitected",
sha256: await import("crypto").then(m => m.createHash),
});
const request = new HttpRequest({
protocol: "https:",
hostname: "wellarchitected.us-east-1.amazonaws.com",
path: "/preview/workload",
method: "POST",
headers: {
"content-type": "application/json",
"accept": "application/json",
},
body,
});
const signed = await signer.sign(request);
const response = await fetch(`https://${signed.hostname}${signed.path}`, {
method: signed.method,
headers: signed.headers,
body: signed.body,
});
if (response.status !== 202) {
console.error(`❌ Unexpected status ${response.status}`);
const text = await response.text();
console.error("Body:", text);
return;
}
// The API returns an operation ID that we poll later.
const { operationId } = await response.json();
console.log("✅ Review accepted, operationId:", operationId);
return operationId;
}
Comments:
-
Signed request – AWS APIs require SigV4 signing; we create a signed
HttpRequestthen feed it tofetch. -
202‑Accepted – means the review is queued. The response body contains
operationIdto track progress. - S3 payload – the Agent does not accept raw JSON in the POST body; it only reads from a URL, so we store the definition in a temporary S3 object and generate a signed URL.
Key takeaway: The preview workflow is a two‑step dance: (1) upload your IaC description somewhere the service can reach, (2) tell the Agent where to find it via a signed POST.
Parsing the Agent’s Recommendations and Correlating GuardDuty Findings
Why it matters – The Agent’s output is a list of recommendations, each tied to a resource and a pillar. Those recommendations are helpful, but you also want to know whether a security finding from GuardDuty overlaps with an AI‑suggested fix. Correlating the two gives you a single view of what the AI thinks and what AWS security services have actually detected.
Polling for the final result
import {
WellArchitectedClient,
GetReviewCommand,
} from "@aws-sdk/client-wellarchitected";
async function pollReviewResult(operationId: string, timeoutSec = 120) {
const client = new WellArchitectedClient({ region: "us-east-1" });
const start = Date.now();
while (true) {
const resp = await client.send(new GetReviewCommand({ WorkloadId: operationId }));
if (resp.Status === "COMPLETED") {
console.log("✅ Review finished.");
return resp.Review; // contains Recommendations array
}
if (Date.now() - start > timeoutSec * 1000) {
throw new Error("Timed out waiting for review to complete");
}
console.log("⏳ Still processing…");
await new Promise(r => setTimeout(r, 5_000)); // wait 5 seconds
}
}
Comments:
-
GetReviewCommand – fetches the review associated with the
operationId. - The loop checks
Statusuntil it becomesCOMPLETED.
Pulling GuardDuty findings
import {
GuardDutyClient,
ListFindingsCommand,
GetFindingsCommand,
} from "@aws-sdk/client-guardduty";
async function fetchGuardDutyFindings(detectorId: string) {
const client = new GuardDutyClient({ region: "us-east-1" });
// First, list the finding IDs (max 50 per call for simplicity).
const listResp = await client.send(
new ListFindingsCommand({ DetectorId: detectorId })
);
if (!listResp.FindingIds?.length) {
console.log("✅ No GuardDuty findings.");
return [];
}
// Then, retrieve the full finding objects.
const getResp = await client.send(
new GetFindingsCommand({
DetectorId: detectorId,
FindingIds: listResp.FindingIds,
})
);
return getResp.Findings ?? [];
}
Comments:
-
DetectorId – the GuardDuty detector that belongs to your account; you can find it in the GuardDuty console or via
ListDetectorsCommand. - The code pulls both IDs and details so we can match on resource ARN.
Merging the two data sets
type Recommendation = {
ResourceId: string; // matches the LogicalId from the workload definition
Pillar: string;
Text: string;
};
type GuardDutyFinding = {
Id: string;
Resource: {
InstanceDetails?: { InstanceArn?: string };
AccessKeyDetails?: { AccessKeyId?: string };
// GuardDuty can report many resource types; we’ll look at the ARN field.
ResourceArn?: string;
};
Severity: number;
Title: string;
};
function correlate(
recommendations: Recommendation[],
findings: GuardDutyFinding[]
) {
const map: Record<string, { recommendation?: Recommendation; findings: GuardDutyFinding[] }> = {};
// Index by resource ID (LogicalId) for quick lookup.
recommendations.forEach(rec => {
map[rec.ResourceId] = { recommendation: rec, findings: [] };
});
// Walk through each finding and see if its ARN contains a known logical ID.
findings.forEach(f => {
const arn = f.Resource?.ResourceArn ?? "";
// Simple heuristic: the logical ID appears somewhere in the ARN string.
const matchKey = Object.keys(map).find(id => arn.includes(id));
if (matchKey) {
map[matchKey].findings.push(f);
}
});
return map;
}
Comments:
- The correlation uses a string contains check, which works for most CDK‑generated ARNs because the logical ID appears in the resource name.
- This is a lightweight analogy: imagine matching a list of “to‑do” items (recommendations) with a list of “found problems” (GuardDuty) by looking for a common word in both notes.
Example of printing the merged view
async function main() {
const operationId = await callWellArchitectedAgent(); // from previous section
if (!operationId) return;
const review = await pollReviewResult(operationId);
const recommendations = review?.Recommendations?.map(r => ({
ResourceId: r.Resource?.LogicalId ?? "unknown",
Pillar: r.Pillar ?? "unknown",
Text: r.Text ?? "",
})) ?? [];
// Replace with your own detector ID.
const detectorId = "12abc34def567ghijk890lmn";
const findings = await fetchGuardDutyFindings(detectorId);
const merged = correlate(recommendations, findings);
for (const [resourceId, entry] of Object.entries(merged)) {
console.log(`\n🔧 Resource: ${resourceId}`);
if (entry.recommendation) {
console.log(` • AI advice (${entry.recommendation.Pillar}): ${entry.recommendation.Text}`);
}
if (entry.findings.length) {
console.log(` • GuardDuty alerts (${entry.findings.length}):`);
entry.findings.forEach(f => {
console.log(` - ${f.Title} (severity ${f.Severity})`);
});
} else {
console.log(" • No GuardDuty findings.");
}
}
}
main().catch(console.error);
Helpful tip: Run the script with
NODE_OPTIONS=--enable-source-mapsso stack traces point to the original TypeScript line numbers, making debugging easier.
Best‑Practice Loop: Auto‑Update Your IaC Based on AI Feedback
Why it matters – The Agent is great at pointing out missing encryption, overly permissive policies, or absent monitoring. If you treat its output as a one‑off report, you miss the chance to keep your codebase continuously aligned with best practices. By looping the review, applying fixes, and re‑submitting, you create a feedback cycle similar to a lint‑and‑fix pipeline for code quality.
High‑level loop description
- Run the Agent → get recommendations.
- Parse recommendations → identify which resources need a change.
- Programmatically edit the CDK (or Terraform) source files.
- Commit the changes (Git) and optionally open a Pull Request.
- Re‑run the Agent to verify that the issue is resolved.
Think of it like a spell‑checker that automatically applies suggested corrections and then re‑checks the document.
Code sketch that updates a CDK stack file
import * as fs from "node:fs";
import * as path from "node:path";
/**
* Very simple helper that adds versioning to an S3 bucket if the AI
* recommendation mentions "Enable versioning".
*/
function applyVersioningIfNeeded(
stackFile: string,
recommendations: Recommendation[]
) {
let content = fs.readFileSync(stackFile, "utf-8");
recommendations.forEach(rec => {
if (
rec.ResourceId === "AssetsBucket" &&
rec.Text.toLowerCase().includes("enable versioning")
) {
// Insert a line right after the bucket definition (naïve string replace).
const bucketPattern = /new s3\.Bucket\(this,\s*["']AssetsBucket["'],\s*{[^}]*}\);/s;
const match = bucketPattern.exec(content);
if (match) {
const insertion = `\n versioned: true, // Added by Well‑Architected Agent`;
const updated = match[0].replace(/}\);/, `${insertion}\n});`);
content = content.replace(bucketPattern, updated);
console.log("✅ Added versioning flag to AssetsBucket.");
}
}
});
fs.writeFileSync(stackFile, content, "utf-8");
}
// Example usage
applyVersioningIfNeeded(
path.resolve("lib/my-sample-app-stack.ts"),
[
{
ResourceId: "AssetsBucket",
Pillar: "Security",
Text: "Enable versioning on the S3 bucket to protect against accidental deletions."
}
]
);
Comments:
- This is illustrative only; a production‑grade tool would manipulate the CDK AST (Abstract Syntax Tree) rather than plain string replacement.
- The function reads the stack file, looks for a bucket definition that matches the logical ID, and injects
versioned: true.
Automating the loop with npm scripts
{
"scripts": {
"review": "node ./scripts/run-review.js", // the script from previous sections
"apply-fixes": "node ./scripts/apply-fixes.js",
"ci": "npm run review && npm run apply-fixes && git diff --exit-code"
}
}
Running npm run ci will:
- Call the Agent,
- Apply any simple fixes,
- Fail the CI job if the repository changed (meaning a fix was applied).
In plain English: The CI pipeline becomes a “self‑healing” guard that refuses to merge code unless the AI‑driven review is clean.
The Takeaway
Key points you should walk away with
- The Well‑Architected Agent is a preview‑only service (us‑east‑1) that uses a generative‑AI model to turn IaC into a structured review.
- Enabling the preview flag and granting
wellarchitected:EnableAgentPrevieware mandatory; otherwise you’ll see a misleading 403 error. - Because the preview endpoint expects a URL, you must upload your workload definition to S3 and sign the request before posting with native
fetch. - The API returns a 202 response with an
operationId; pollGetReviewuntil the status becomesCOMPLETEDto fetch the recommendations. - Correlating those recommendations with GuardDuty findings gives a unified security picture; a simple string‑contains match works for most CDK‑generated ARNs.
- Embedding the review into a repeatable loop (review → apply → re‑review) lets you keep your CDK/Terraform code continuously aligned with AWS best practices, much like a lint‑and‑fix cycle for source code.
By following the steps above, you can replace a manual, weeks‑long architecture review with a few lines of code that run on every pull request, surface real security findings, and even auto‑apply low‑risk fixes. Happy building!
Transparency notice
This article was written with the help of an AI system — Groq (GPT OSS 120B).
Published: 2026-10-02 · Primary focus: WellArchitectedAgent
All code blocks are intended to be correct and runnable, but please verify them
against the official docs for the tools mentioned before using in production.Find an error? Drop a comment — corrections are always welcome.
Top comments (0)