{"slug": "i-built-a-code-roasting-rubber-duck", "title": "I Built a Code-Roasting Rubber Duck", "summary": "A developer built Unducked, a code-review tool that uses a foul-mouthed rubber duck persona to roast code and find bugs. The project is implemented as a single TypeScript file using AWS Agent Toolkit, Bedrock (Amazon Nova Lite), and a Strands agent, deployed via a Lambda proxy and CloudFront.", "body_md": "Every developer knows rubber-duck debugging: you explain your code to a rubber duck on your desk, and halfway through the explanation you spot the bug yourself. The duck just sits there. Silent. Judging.\n\nI wanted a duck that judges *out loud*.\n\nSo I built ** Unducked**. Paste in your code, and a foul-mouthed rubber duck reviews it like Gordon Ramsay reviews a risotto. It roasts you. It calls your function RAW. And then, annoyingly, it finds the actual bug and hands you the fix.\n\n**Try it right now: unducked.com.** There's a\n\nIt's genuinely useful (the roast is a real code review) and it's the kind of thing you screenshot and send to the group chat.\n\nThe whole thing is one TypeScript file, a cheap model, and a public streaming endpoint on AWS. Two things surprised me building it, and both are the interesting part of this post:\n\nHere's how to build your own.\n\nThe \"AI\" here isn't complicated. An agent is just a model with a personality bolted on via a system prompt. That's the entire trick.\n\nHere's the shape of what we're building:\n\n```\nBrowser (unducked.com)\n  → CloudFront + Lambda proxy   (public HTTPS; signs requests for the browser)\n    → AgentCore Runtime          (hosted agent endpoint)\n      → Strands Agent            (Chef Duck persona)\n        → Bedrock (Amazon Nova Lite)\n```\n\nYou write a Strands agent in TypeScript. The AgentCore CLI deploys it as a hosted endpoint on AWS. A tiny Lambda proxy makes that endpoint safely callable from a browser. No hand-written Lambda business logic, no API Gateway, no Docker. Just TypeScript and a couple of CLI commands.\n\nYou'll need an AWS account, Node.js 22+, and npm. You also need AWS credentials and a couple of CLI tools on your machine.\n\n**The fast path (let an AI agent do it).** If you use a coding agent (Claude Code, Cursor, Kiro, Codex), hand it this and let it set everything up for you:\n\n```\nSet up Agent Toolkit for AWS by following these instructions:\nhttps://raw.githubusercontent.com/aws/agent-toolkit-for-aws/refs/heads/main/setup-instructions/setup.md\n```\n\nIt configures credentials and installs the AWS tooling in one shot.\n\n**Or, manually:**\n\n```\n# 1. Install the AWS CLI (macOS shown; see AWS docs for other platforms)\nbrew install awscli\n\n# 2. Configure credentials, then verify they work\naws configure\naws sts get-caller-identity\n\n# 3. Install the AgentCore CLI and the AWS CDK (AgentCore uses CDK to deploy)\nnpm install -g @aws/agentcore aws-cdk\n```\n\nOne more thing: in the Bedrock console, enable model access for **Amazon Nova Lite**. That's your toolchain.\n\nOne command scaffolds everything:\n\n```\nagentcore create agent \\\n  --name Unducked \\\n  --type create \\\n  --build CodeZip \\\n  --language TypeScript \\\n  --framework Strands \\\n  --model-provider Bedrock \\\n  --memory none\n```\n\nYou get this structure:\n\n```\nUnducked/\n├── agentcore/                # Config + CDK (you won't touch this)\n└── app/Unducked/\n    ├── main.ts               # The agent ← the file that matters\n    ├── model/load.ts         # Which Bedrock model to use\n    ├── package.json\n    └── tsconfig.json\n```\n\nThe scaffold drops in an example tool and an MCP client. Nice for later, but we'll strip them out for a pure roasting duck.\n\nThis is where the personality lives, and it's the whole product.\n\n``` js\n// app/Unducked/main.ts\nimport { BedrockAgentCoreApp } from 'bedrock-agentcore/runtime';\nimport { Agent } from '@strands-agents/sdk';\nimport { loadModel } from './model/load.js';\n\nconst SYSTEM_PROMPT = `You are Chef Duck — a foul-mouthed-but-brilliant rubber\nduck that reviews code like Gordon Ramsay runs a kitchen.\n\n- Open with a short, savage roast of the CODE (never the person). Kitchen\n  metaphors encouraged: \"this function is RAW\", \"it's so nested it's got its\n  own zip code\".\n- Then ACTUALLY HELP. Every roast must name the concrete bug and give the fix.\n  Useful first, funny second.\n- The \"no bug\" path: if the code has no real defect, roast it for being\n  boring, concede in one line (\"...fine. It's not garbage.\"), and STOP.\n  Type annotations, input validation, and null checks are NOT bugs — never\n  suggest them for code that already works. Inventing improvements is failing.\n- Keep it tight. PG-13 — spicy, not vile. Plain prose, no headings, a fenced\n  code block for the fix.`;\n\nconst model = loadModel();\n\n// One Agent per session so follow-up questions keep the roast in context.\nconst agents = new Map<string, Agent>();\n\nconst app = new BedrockAgentCoreApp({\n  invocationHandler: {\n    async *process(payload: any, context: any) {\n      const sessionId = context?.sessionId ?? 'default-session';\n      let agent = agents.get(sessionId);\n      if (!agent) {\n        agent = new Agent({ model, systemPrompt: SYSTEM_PROMPT });\n        agents.set(sessionId, agent);\n      }\n\n      for await (const event of agent.stream(payload.prompt ?? '')) {\n        if (\n          event.type === 'modelStreamUpdateEvent' &&\n          event.event?.type === 'modelContentBlockDeltaEvent' &&\n          event.event.delta?.type === 'textDelta'\n        ) {\n          yield { data: event.event.delta.text };\n        }\n      }\n    },\n  },\n});\n\napp.run({ port: parseInt(process.env.PORT ?? '8080') });\n```\n\nThree pieces:\n\n`BedrockAgentCoreApp`\n\n`agent.stream()`\n\nand yielding each text delta.`loadModel()`\n\npoints at **Amazon Nova Lite**, ~$0.06/$0.24 per million tokens on Bedrock, so roasts cost a fraction of a cent. But here's the thing that took the most iteration: **the hardest part of the prompt is the \"no bug\" path.** Cheap models are desperate to be helpful. Hand them working code and they'll \"improve\" it with type checks and validation nobody asked for, which ruins the joke and gives bad advice. The prompt has to explicitly forbid that and tell the duck to just concede when the code is fine. Getting a cheap model to *shut up* was harder than getting it to roast.\n\nGotcha that cost me time:the stream emits several event types, and you can only reach`event.event`\n\nafter narrowing on`event.type`\n\nfirst. The three-part`if`\n\nabove is what actually compiles. A bare`event.event?.delta?.type`\n\nthrows a TypeScript error. Copy it exactly.\n\nSwap the model ID in `model/load.ts`\n\nfor Claude Haiku or Sonnet if you want more polish.\n\n`agentcore dev`\n\n```\nagentcore dev\n```\n\nIn another terminal:\n\n```\nagentcore dev \"function last(arr) { return arr[arr.length]; }\"\n```\n\nYou'll get an off-by-one roast streamed back, live. If that works, your duck is alive.\n\n```\nagentcore deploy\n```\n\nThe CLI compiles your TypeScript, packages it, uses CDK to stand up the IAM roles and an AgentCore Runtime endpoint, and wires up CloudWatch logging. First deploy takes a few minutes while CDK bootstraps; after that it's fast.\n\n``` python\nagentcore invoke \"def add(a, b): return a - b\" --stream\n```\n\nIf the duck tells you your `add`\n\nfunction is a liar, you're live on AWS.\n\nHere's the wrinkle nobody warns you about. Your agent is deployed, but the AgentCore endpoint requires **AWS SigV4-signed requests**. A browser can't call it directly, and you must **never** sign from client-side JS (that ships your AWS credentials in the page source). So you need something in the middle that holds an IAM role and signs on the browser's behalf.\n\nThe obvious move, a public Lambda Function URL with `AuthType: NONE`\n\n, *does not work*. The reason is a great story: AWS's own security tooling detects the world-accessible Lambda and automatically scopes the permissions back down. Your calls quietly start returning `Forbidden`\n\n. The platform is protecting you from yourself.\n\nThe setup that actually holds up:\n\n```\nCloudFront distribution   (public HTTPS, injects CORS, SigV4-signs to origin)\n  → Lambda Function URL   (AuthType = AWS_IAM, streaming proxy)\n    → AgentCore Runtime   (InvokeAgentRuntime)\n```\n\nCloudFront is the public face. It signs each request to a private, IAM-authed Lambda using an **Origin Access Control (OAC)**. The Lambda is never world-accessible; CloudFront is. The Lambda itself is tiny: it forwards the prompt to the runtime and streams the SSE response straight back:\n\n``` js\n// proxy/index.mjs — the whole proxy, minus CORS boilerplate\nexport const handler = awslambda.streamifyResponse(async (event, responseStream) => {\n  const { prompt } = JSON.parse(event.body ?? '{}');\n\n  const res = await client.send(new InvokeAgentRuntimeCommand({\n    agentRuntimeArn: RUNTIME_ARN,\n    runtimeSessionId: sessionId,        // AgentCore requires ≥ 33 chars\n    accept: 'text/event-stream',\n    contentType: 'application/json',\n    payload: new TextEncoder().encode(JSON.stringify({ prompt })),\n  }));\n\n  // The runtime already emits well-formed `data: ...\\n\\n` SSE frames. Forward verbatim.\n  for await (const chunk of res.response) responseStream.write(chunk);\n  responseStream.end();\n});\n```\n\nTwo gotchas here each cost me an afternoon, so I'll save you both:\n\n`x-amz-content-sha256`\n\nheader.`lambda:InvokeFunctionUrl`\n\nand `lambda:InvokeFunction`\n\n.`Forbidden`\n\n.The repo's [ blogs/deployment-notes.md](https://github.com/tmoreton/tutorials/blob/main/blogs/deployment-notes.md) has the exact CLI commands for the proxy, the OAC, the CORS response-headers policy, and the IAM. Reproduce it from scratch in a few minutes.\n\nThe UI is one HTML file, no build step, and I'm going to spend almost no time on it because the interesting work is behind it. It's a paste box, an ASCII duck, and that dice button. The only part that matters is how it talks to the agent: **send the code, read back a Server-Sent Events stream**.\n\n``` js\nconst res = await fetch(API_URL, {\n  method: \"POST\",\n  headers: {\n    \"Content-Type\": \"application/json\",\n    \"Accept\": \"text/event-stream\",  // required: the agent streams SSE, not JSON\n    \"X-Amzn-Bedrock-AgentCore-Runtime-Session-Id\": sessionId,\n    // For prod, CloudFront's OAC needs the body hash (see Step 6):\n    \"x-amz-content-sha256\": await sha256Hex(body),\n  },\n  body: JSON.stringify({ prompt: code }),\n});\n\nconst reader = res.body.getReader();\nconst decoder = new TextDecoder();\nlet buffer = \"\";\nwhile (true) {\n  const { done, value } = await reader.read();\n  if (done) break;\n  buffer += decoder.decode(value, { stream: true });\n  const frames = buffer.split(\"\\n\\n\");\n  buffer = frames.pop();                 // keep any partial frame\n  for (const frame of frames) {\n    const line = frame.split(\"\\n\").find((l) => l.startsWith(\"data:\"));\n    if (line) onToken(JSON.parse(line.slice(5).trim()));  // append token to the page\n  }\n}\n```\n\nTwo things to remember: the server **requires** `Accept: text/event-stream`\n\n(without it you get a JSON error, not a stream), and the response is a stream of token strings, not one JSON blob. That's what makes the roast type out live, like the duck is thinking. Locally the frontend detects `localhost`\n\nand skips CloudFront, talking straight to `agentcore dev`\n\non port 8080.\n\nOne safety note since you're injecting model output into the page: **escape everything before you format any Markdown.** A dozen lines of regex handles bold and code fences without letting raw HTML through.\n\nPush to GitHub, then **Settings → Pages → Deploy from branch main, folder /**. A minute later your duck is live. HTTPS, free, auto-deploying on every push. Point a custom domain at it (I use\n\n`unducked.com`\n\n), set the frontend's production endpoint to your CloudFront URL, and you've got a product.\n\n```\ngit clone https://github.com/tmoreton/tutorials\nopen tutorials/index.html\n```\n\nThe endpoint is public and unauthenticated, so anyone with the URL can spend your Bedrock tokens. Nova Lite is cheap (a fraction of a cent per roast), but a viral moment shouldn't become a surprise invoice, so at minimum:\n\nThe whole appeal of Unducked is that you click a link and roast some code, no signup, no API key. That's also the problem: a public, unauthenticated endpoint is a standing invitation for someone to script a loop against it, drain your token budget, and lock everyone else out. The goal is to make that *expensive and annoying* for an abuser while staying *frictionless* for a real visitor. Here's the stack of defenses I settled on, cheapest first. None of them ask the user to sign in.\n\n**1. Cap the input size (already in the proxy).** The single biggest lever on cost is how many tokens each request carries. A roast needs a snippet, not a novel, so the proxy truncates the prompt before it ever reaches Bedrock:\n\n``` js\nconst MAX_PROMPT_CHARS = parseInt(process.env.MAX_PROMPT_CHARS ?? '8000');\n// ...\nconst prompt = (body.prompt ?? '').slice(0, MAX_PROMPT_CHARS);\n```\n\nThat one line turns \"paste a 2 MB file and cost me dollars\" into a non-event. It also bounds output indirectly because the system prompt already tells the duck to keep it tight.\n\n**2. Reserved concurrency is your circuit breaker.** The Lambda cap from above isn't just about tokens; it's the ceiling on *total throughput*. With a reserved concurrency of 2, there is no amount of traffic that makes the bill run away; excess requests get throttled at the proxy, not billed at Bedrock. Set it deliberately low and treat it as the backstop behind everything else.\n\n**3. Put AWS WAF in front of CloudFront. This is the real fix.** WAF (Web Application Firewall) is a rules engine that sits *in front of* your CloudFront distribution and inspects every request before it reaches your origin. Nothing changes in your Lambda or your frontend; you attach a \"Web ACL\" (a bundle of rules) to the distribution and CloudFront enforces it. For a public toy the one rule that matters is a **rate limit**, and it needs no login:\n\n`403`\n\n, set the over-limit action to `curl`\n\nloop or headless scraper fails. The challenge To enable it, in the **WAF console**: create a Web ACL, set **Resource type → CloudFront distributions**, associate your distribution, add a **rate-based rule** (limit `100`\n\n, aggregate on **source IP**, evaluation window `5 minutes`\n\n), set its action to **Challenge**, and save. Or the one CLI call that does the same thing (CloudFront Web ACLs live in `us-east-1`\n\n):\n\n```\naws wafv2 create-web-acl \\\n  --name unducked-rate-limit --scope CLOUDFRONT --region us-east-1 \\\n  --default-action Allow={} \\\n  --visibility-config SampledRequestsEnabled=true,CloudWatchMetricsEnabled=true,MetricName=unducked \\\n  --rules '[{\"Name\":\"rate-per-ip\",\"Priority\":0,\n    \"Statement\":{\"RateBasedStatement\":{\"Limit\":100,\"AggregateKeyType\":\"IP\",\"EvaluationWindowSec\":300}},\n    \"Action\":{\"Challenge\":{}},\n    \"VisibilityConfig\":{\"SampledRequestsEnabled\":true,\"CloudWatchMetricsEnabled\":true,\"MetricName\":\"rate-per-ip\"}}]'\n# then associate the returned Web ACL ARN with the distribution (set it as the distribution's WebACLId)\n```\n\nThat's exactly what's guarding `unducked.com`\n\nright now. WAF's own logs and CloudWatch metrics then show you who got challenged, so you can watch for abuse without watching the bill.\n\n**4. Keep CORS locked to your origin.** The proxy already restricts `Access-Control-Allow-Origin`\n\nto `https://unducked.com`\n\n. It won't stop a determined attacker (CORS is browser-enforced, and curl ignores it), but it stops the lazy case where someone embeds your endpoint from their own site.\n\nThe honest limit: without authentication you can't make abuse *impossible*, only *uneconomical*. But layering all four (an input cap, a hard concurrency ceiling of 2, a WAF rate-limit-plus-challenge, and CORS locked to the origin) is exactly what's live on `unducked.com`\n\n, and together they mean a casual attacker bounces off while a real visitor never notices a thing. A budget alarm catches anything that slips through. If it ever went truly viral-with-a-target-on-its-back, the next step would be a lightweight anonymous token (a per-session nonce your page mints), but for a code-roasting duck that's overkill.\n\n| Layer | What | How |\n|---|---|---|\n| Personality | A system prompt | The whole product, really |\n| Model | Amazon Nova Lite | Amazon Bedrock |\n| Agent | ~30 lines of TypeScript | Strands Agents SDK + AgentCore |\n| Backend hosting | `agentcore deploy` |\nAgentCore Runtime |\n| Public endpoint | Streaming Lambda + CloudFront | Signs requests for the browser |\n| Frontend hosting | Push to GitHub | GitHub Pages |\n\nThe lesson underneath the jokes: a capable model plus a sharp system prompt is a shippable product, and the AI is the *cheap* part. The fiddly work is the plumbing that makes it public and safe. Change the prompt and Chef Duck becomes a patient mentor, a passive-aggressive senior dev, or a security auditor. Same stack, same afternoon.\n\n[The complete code is on GitHub →](https://github.com/tmoreton/tutorials)\n\nGo roast some code. Your duck is disappointed in you already.", "url": "https://wpnews.pro/news/i-built-a-code-roasting-rubber-duck", "canonical_source": "https://dev.to/tmoreton/i-built-a-code-roasting-rubber-duck-39d2", "published_at": "2026-07-29 14:14:06+00:00", "updated_at": "2026-07-29 14:36:09.747959+00:00", "lang": "en", "topics": ["developer-tools", "artificial-intelligence", "large-language-models", "ai-products"], "entities": ["Unducked", "AWS", "Bedrock", "Amazon Nova Lite", "Strands", "AgentCore", "CloudFront", "Lambda"], "alternates": {"html": "https://wpnews.pro/news/i-built-a-code-roasting-rubber-duck", "markdown": "https://wpnews.pro/news/i-built-a-code-roasting-rubber-duck.md", "text": "https://wpnews.pro/news/i-built-a-code-roasting-rubber-duck.txt", "jsonld": "https://wpnews.pro/news/i-built-a-code-roasting-rubber-duck.jsonld"}}