<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gabriel Koo</title>
    <description>The latest articles on DEV Community by Gabriel Koo (@gabrielkoo).</description>
    <link>https://dev.to/gabrielkoo</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F915460%2Fd0dc6e3b-e400-47c8-b6fc-ecc9cf80be4b.jpeg</url>
      <title>DEV Community: Gabriel Koo</title>
      <link>https://dev.to/gabrielkoo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gabrielkoo"/>
    <language>en</language>
    <item>
      <title>Your AWS role can't tell a human from an agent anymore, part 5: putting the four layers together</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:54:50 +0000</pubDate>
      <link>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-5-putting-the-four-layers-together-1iko</link>
      <guid>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-5-putting-the-four-layers-together-1iko</guid>
      <description>&lt;p&gt;The reason enterprises fail at agentic tools isn't that the tools are weak — it's that they either hand over the kingdom or lock everything away. The previous four parts give you the alternative:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uedejc208mid2sgpyr1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F8uedejc208mid2sgpyr1.png" alt="The four-layer stack: Identity, Label, Enforce, Detect — no single layer is trusted" width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Failure mode it covers&lt;/th&gt;
&lt;th&gt;What it buys you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Identity (SSO, dedicated role, source identity)&lt;/td&gt;
&lt;td&gt;Long-lived or shared credentials&lt;/td&gt;
&lt;td&gt;Bound and attributable sessions with a short blast window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. UA tag (&lt;code&gt;AWS_SDK_UA_APP_ID&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Missing application telemetry&lt;/td&gt;
&lt;td&gt;A best-effort, filterable trail alongside the principal identity&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. SCP + RCP&lt;/td&gt;
&lt;td&gt;Accidental over-grant and selected destructive actions&lt;/td&gt;
&lt;td&gt;A ceiling even an admin session can't override&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4. Detect (CloudTrail + drift checks)&lt;/td&gt;
&lt;td&gt;Unexplained or unmanaged changes&lt;/td&gt;
&lt;td&gt;Attribution, alerting, and configuration mismatch detection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The rollout order I'd actually use
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Stand up the sandbox first.&lt;/strong&gt; A dedicated member account or OU, ideally with no production data. Get the agent out of shared accounts before you tune anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Kill the long-lived keys.&lt;/strong&gt; SSO everywhere, an agent role with source identity required in its trust policy, session duration capped. Verify the credential provider can refresh.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag the fleet&lt;/strong&gt; (part 2). Managed settings for Claude Code, managed config for Codex, OS-level fallback elsewhere. Confirm with one CloudTrail query that tagged calls are landing before you trust it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attach the SCP&lt;/strong&gt; (part 3) — after the sandbox baseline exists. Expect fallout, keep a log of every exception you grant, and remember each exception is an inheritance path too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Wire detection&lt;/strong&gt; (part 4) before inviting users: the trail-to-Athena path, at least one dashboard query you've actually run, GuardDuty, and a budget alarm.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then open the doors to one squad with one tool&lt;/strong&gt; and watch a quiet week of logs. Widen deliberately; the whole point of cheap layers is that you can afford to iterate.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What this does not solve
&lt;/h2&gt;

&lt;p&gt;These layers bound what the agent can do to AWS. They do not control the rest of the machine. Filesystem isolation, network egress, MCP tool allow-listing, and deciding what may leave the host in a prompt still matter. AWS permissions cannot save a secret after the agent has already read it — and nothing here turns a poisoned context into a safe one. Layer 3 confines it; human approval on mutating actions is still the strongest control you have.&lt;/p&gt;

&lt;p&gt;The honest summary of Layer 2 belongs here too: the UA tag is telemetry. The experiment in part 2 showed exactly how far a tag-conditioned control gets you — one environment variable away from being bypassed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The point of all of this
&lt;/h2&gt;

&lt;p&gt;None of it is heavy sandboxing. None of it requires a remote browser. It's a handful of cheap layers that let your agents actually be agents — read, diagnose, suggest, fix within bounds — while the account stays safe even when an individual engineer does something lazy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You don't choose between "agents are useless" and "agents own my account".&lt;/strong&gt; With the right cheap layers, you get agents that can help with autonomous troubleshooting and an account that caps the blast radius when they get it wrong.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The full series: [[[&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40"&gt;part 1 (threat model and identity)&lt;/a&gt;](&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40"&gt;https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40&lt;/a&gt;), [[[&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac"&gt;part 2 (the UA tag)&lt;/a&gt;](&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac"&gt;https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac&lt;/a&gt;), [[[&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0"&gt;part 3 (the SCP backstop)&lt;/a&gt;](&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0"&gt;https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0&lt;/a&gt;), [[[&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3"&gt;part 4 (detection)&lt;/a&gt;](&lt;a href="https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3)%5D(https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3"&gt;https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3)](https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3&lt;/a&gt;). The complete SCP policy referenced in part 3 is at &lt;a href="https://gabrielkoo.com/devto/ai-agents-aws-sandbox/agent-sandbox-scp.json" rel="noopener noreferrer"&gt;gabrielkoo.com/devto/ai-agents-aws-sandbox/agent-sandbox-scp.json&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I'm an InfoSec + AI engineer (formerly DevSecOps) and AWS Community Builder based in Hong Kong — building secure, pragmatic agentic infrastructure. More writing on AWS security, AI tooling, and infra at gabrielkoo.com.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Your AWS role can't tell a human from an agent anymore, part 4: detection — what did the agent actually touch?</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:54:41 +0000</pubDate>
      <link>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3</link>
      <guid>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-4-detection-what-did-the-agent-3d3</guid>
      <description>&lt;p&gt;Layers 1–3 reduce blast radius; they don't eliminate surprise. Layer 4 closes the loop: make agent behavior something you &lt;em&gt;observe&lt;/em&gt;, not hope about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 4 — Detect: audit and respond
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What to wire up:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail to Athena / a log sink&lt;/strong&gt; — queryable by the UA tag from part 2. This is your "what did the agent actually touch" answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let GuardDuty carry the credential-reuse class first&lt;/strong&gt; — its dedicated credential-exfiltration findings target EC2 instance-profile credentials specifically, and its anomaly findings may still catch a stolen laptop session replayed from somewhere new. Don't reimplement what it covers; just know the laptop-session case rides on the noisier anomaly detections, which is one more reason the layer-1 short session window matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anomaly detection on the dedicated role and source identity first, UA tag second&lt;/strong&gt; — query the role ARN plus CloudTrail's &lt;code&gt;userIdentity.sessionContext.sourceIdentity&lt;/code&gt;; alert on denied actions, bursts outside working hours, a new Region, or calls from the agent role with no expected app ID. The principal and source identity are the durable attribution — caller-declared, but fixed at assume time and carried through role chaining; the tag is supporting evidence. Expect the missing-tag alert to be noisy until you know which clients in your fleet actually participate in part 2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on SCP denials&lt;/strong&gt; — surprisingly effective. Most services report &lt;code&gt;AccessDenied&lt;/code&gt;, while Amazon EC2 commonly uses &lt;code&gt;Client.UnauthorizedOperation&lt;/code&gt;; match the error message too. Cover both explicit-deny messages and the allow-list case where no SCP permits the action. An agent hitting one is either exploring or pushing against a boundary. Both are worth a look.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run CloudFormation drift detection&lt;/strong&gt; — neither the SCP nor the UA tag detects configuration drift. Drift detection finds mismatches against the template; CloudTrail and the UA tag can help attribute the API call that caused them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkm6l4vtt34os5iodob0y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fkm6l4vtt34os5iodob0y.png" alt="The attribution chain: role ARN is durable, sourceIdentity survives chaining, the UA tag is supporting evidence"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two queries to start with, from the Athena table over your CloudTrail bucket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Calls made *as* the agent (session already exists)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;eventtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;eventsource&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;eventname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;useridentity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessioncontext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sourceidentity&lt;/span&gt;
         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;source_identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;useragent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cloudtrail_logs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;useridentity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessioncontext&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sessionissuer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arn&lt;/span&gt;
        &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%:role/Agent%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;eventtime&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-01T00:00:00Z'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;eventtime&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Assumes *of* the agent role (bootstrap / layer-1 gaps)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;eventtime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;useridentity&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arn&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;caller_arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;json_extract_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestparameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.roleArn'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;assumed_role_arn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;json_extract_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestparameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.sourceIdentity'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;source_identity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;useragent&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;cloudtrail_logs&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;eventname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'AssumeRole'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;json_extract_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requestparameters&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'$.roleArn'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;LIKE&lt;/span&gt; &lt;span class="s1"&gt;'%:role/Agent%'&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;eventtime&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="s1"&gt;'2026-09-01T00:00:00Z'&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;eventtime&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's what the first query's output actually looks like against a live account, trimmed to the fields you care about (all four rows are 2026-09-18; &lt;code&gt;eventsource&lt;/code&gt; is just the event name plus &lt;code&gt;.amazonaws.com&lt;/code&gt; — &lt;code&gt;s3&lt;/code&gt;, &lt;code&gt;iam&lt;/code&gt;, &lt;code&gt;dynamodb&lt;/code&gt;, &lt;code&gt;lambda&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eventtime             | eventname      | source_identity | useragent
02:11:41Z             | GetObject      | claude-code-1   | app/ai-agent_eng
02:11:42Z             | ListRoles      | claude-code-1   | app/ai-agent_eng
03:58:17Z             | DescribeTable  | (empty)         | app/ai-agent_eng
09:15:33Z             | InvokeFunction | claude-code-1   | app/ai-agent_eng …
                      |                |                 | CloudShell
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sample includes &lt;code&gt;GetObject&lt;/code&gt; and &lt;code&gt;InvokeFunction&lt;/code&gt;, which are CloudTrail &lt;strong&gt;data events&lt;/strong&gt;. A default management-events trail will not show them. Enable data events on the S3 buckets and Lambda functions you actually care about (and on Bedrock agent / async APIs, which are data events too — plain &lt;code&gt;Converse&lt;/code&gt; / &lt;code&gt;InvokeModel&lt;/code&gt; already arrive as management events), or the "what did it touch" answer is only the control plane.&lt;/p&gt;

&lt;p&gt;A few reads to steal from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source_identity&lt;/code&gt; filled + tag present&lt;/strong&gt; (rows 1–2): the expected agent path — hardened assume from part 1, tagged from part 2. Quiet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;source_identity&lt;/code&gt; empty but tagged&lt;/strong&gt; (row 3): the call carried the app ID but &lt;em&gt;not&lt;/em&gt; a source identity — the assume path didn't go through the hardened bridge from part 1. A gap in your own layer 1, worth a follow-up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tag present but the calling client is CloudShell, not the agent&lt;/strong&gt; (row 4): the app ID rode in, but the actual caller is something else — check whether your MDM config leaked into a non-agent tool, or an agent is wrapping a different toolchain. The User-Agent shows the app ID prefix plus the real client.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For the bootstrap story, run the second query and read &lt;code&gt;sourceIdentity&lt;/code&gt; / &lt;code&gt;userAgent&lt;/code&gt; on &lt;em&gt;that&lt;/em&gt; event. A successful hardened assume shows the source identity in &lt;code&gt;requestParameters.sourceIdentity&lt;/code&gt;; empty there means the caller didn't set it, which is a layer-1 gap.&lt;/p&gt;

&lt;p&gt;Two traps before you trust this query, and both bit me live.&lt;/p&gt;

&lt;p&gt;First, the schema. A stock Athena &lt;code&gt;cloudtrail_logs&lt;/code&gt; DDL doesn't declare the source-identity field inside the session-context struct — add &lt;code&gt;sourceidentity:STRING&lt;/code&gt; there first or the column won't resolve. And for an assumed-role call, the principal ARN on the event is the &lt;em&gt;session&lt;/em&gt; ARN, not the role: the role you're hunting lives one level down in the session issuer field, which is what the &lt;code&gt;WHERE&lt;/code&gt; clause above matches. Adjust the table name and time range to your setup — a partitioned table takes &lt;code&gt;region&lt;/code&gt;/&lt;code&gt;year&lt;/code&gt;/&lt;code&gt;month&lt;/code&gt;/&lt;code&gt;day&lt;/code&gt; filters instead.&lt;/p&gt;

&lt;p&gt;Second, the pattern. &lt;code&gt;LIKE '%:role/Agent%'&lt;/code&gt; is greedy — it matches every role with "Agent" in its name, so on a multi-role fleet you'll pull rows you don't intend to attribute. Narrow the pattern to your actual role names, or reserve a dedicated prefix for agent roles, or you'll chase ghost rows.&lt;/p&gt;

&lt;p&gt;Then read the gaps. Rows with an empty source identity did not come through the hardened assume path from part 1, and rows missing your app ID came from a client that doesn't participate in part 2. Those gaps are usually where the story is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; putting the four layers together — the model, the rollout order, and the parts that are still on you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Amazon Athena — &lt;a href="https://docs.aws.amazon.com/athena/latest/ug/create-cloudtrail-table.html" rel="noopener noreferrer"&gt;Create a table for CloudTrail logs&lt;/a&gt; (stock DDL, partitions)&lt;/li&gt;
&lt;li&gt;AWS CloudTrail — &lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-event-reference-user-identity.html" rel="noopener noreferrer"&gt;&lt;code&gt;userIdentity&lt;/code&gt; element&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS CloudTrail — &lt;a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-context-events.html" rel="noopener noreferrer"&gt;Enrich CloudTrail events with IAM global condition keys&lt;/a&gt; (CloudTrail Lake event data stores only)&lt;/li&gt;
&lt;li&gt;AWS CloudFormation — &lt;a href="https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/detect-drift-stack.html" rel="noopener noreferrer"&gt;Detect drift on an entire stack&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon EC2 — &lt;a href="https://docs.aws.amazon.com/ec2/latest/devguide/errors-overview.html" rel="noopener noreferrer"&gt;Error codes&lt;/a&gt; (&lt;code&gt;Client.UnauthorizedOperation&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Your AWS role can't tell a human from an agent anymore, part 3: the SCP backstop</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:54:32 +0000</pubDate>
      <link>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0</link>
      <guid>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-3-the-scp-backstop-59k0</guid>
      <description>&lt;p&gt;Part 1 gave the agent its own identity; part 2 made its calls visible. Neither of those stops a determined mistake. This part is the one that holds when they do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8n53vb6ruo187rprxcl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc8n53vb6ruo187rprxcl.png" alt="The two SCP carve-outs — management account and service-linked roles — plus the external-principal scope gap that RCPs close" width="800" height="412"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 3 — Enforce: SCP as the hard backstop
&lt;/h2&gt;

&lt;p&gt;No matter how careful you are with identity and action-level IAM, give enough engineers this pattern and someone will reuse their &lt;strong&gt;admin role&lt;/strong&gt;, or the one with &lt;code&gt;iam:*&lt;/code&gt;, or a production break-glass role. Policy review culture only gets you so far.&lt;/p&gt;

&lt;p&gt;That's where &lt;strong&gt;Service Control Policies (SCPs)&lt;/strong&gt; come in. This is the layer that still holds when a developer accidentally gives the agent too much IAM access — within limits worth knowing before you rely on it.&lt;/p&gt;

&lt;p&gt;An SCP is a permissions ceiling for principals in a member account. You can't override an explicit SCP deny with an IAM allow. Even an &lt;code&gt;AdministratorAccess&lt;/code&gt; session cannot perform an action that the applicable SCP denies — I attached a one-line deny to a scratch member account, and its admin session failed the call with an explicit deny in a service control policy.&lt;/p&gt;

&lt;p&gt;Three carve-outs matter. SCPs &lt;strong&gt;don't affect the management account&lt;/strong&gt;, they &lt;strong&gt;don't restrict service-linked roles&lt;/strong&gt;, and they constrain principals in your organization — not an external principal that a resource policy lets in. Keep agents in member accounts. If cross-account access matters, pair SCPs with &lt;strong&gt;resource control policies (RCPs)&lt;/strong&gt;, which constrain access to supported resources in member accounts even when the caller is external. RCP support is service-specific and changes over time, so check AWS's current supported-service list before relying on it.&lt;/p&gt;

&lt;p&gt;An SCP &lt;strong&gt;never grants permission&lt;/strong&gt;. The agent role's identity policy must grant the reads and bounded writes it needs. A permissions boundary, like an SCP, only caps what that identity policy can produce — it never grants anything on its own. The SCP sits above both as a coarse guardrail for controls that should hold across a dedicated sandbox OU or account.&lt;/p&gt;

&lt;p&gt;If the default &lt;code&gt;FullAWSAccess&lt;/code&gt; SCP remains attached, the following is a deny-list: anything not denied can still be granted by IAM. If you replace &lt;code&gt;FullAWSAccess&lt;/code&gt; with an allow-list SCP, an explicit allow must exist at every level from the organization root to the account — and IAM must still grant the action. Mixing those two models without understanding the intersection is how people lock out an OU.&lt;/p&gt;

&lt;p&gt;Here is a deny-list starting point for a dedicated agent sandbox. It blocks common IAM escalation paths, account escape hatches, and a selected set of high-impact deletions. It is deliberately &lt;strong&gt;not&lt;/strong&gt; advertised as complete CUD coverage. Replace the example account ID and role names before deployment; the exceptions do not grant permission, so the roles still need tightly scoped IAM policies and trust policies.&lt;/p&gt;

&lt;p&gt;The shape: deny the escalation and destruction classes outright, then carve back the two exceptions the sandbox genuinely needs. An excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"BlockPrivilegeEscalation"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:AttachRolePolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateAccessKey"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:CreateUser"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:UpdateAssumeRolePolicy"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ReserveInlineRolePolicyMutationForIR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Deny"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:DeleteRolePolicy"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"iam:PutRolePolicy"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnNotEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:PrincipalArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:role/AgentBreakGlassIncidentResponse"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;em&gt;(This excerpt is valid JSON but incomplete — the full policy has seven statements, with thirty-one actions in the escalation class alone. Complete file, validated with IAM Access Analyzer (zero findings): &lt;a href="https://gabrielkoo.com/devto/ai-agents-aws-sandbox/agent-sandbox-scp.json" rel="noopener noreferrer"&gt;agent-sandbox-scp.json&lt;/a&gt;.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A couple of practical notes from running this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Put agents in a dedicated member account or OU — ideally one with no production data.&lt;/strong&gt; An SCP applies to the account, not just to requests carrying the UA tag, and attaching this to a shared development account can also break human and CI workflows. When the sandbox holds nothing that matters, you can keep the deny-list blunt instead of carving exceptions forever.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start narrow, then widen.&lt;/strong&gt; Block the highest-blast-radius actions first, observe what legitimate workflows trip over, then expand deliberately. The sample is a starting point, not a complete list of every damaging AWS action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deny the outcome, not only the obvious API.&lt;/strong&gt; Blocking instance termination does not stop Auto Scaling from shrinking the fleet through a service-linked role. Blocking object deletion does not stop a lifecycle rule from expiring objects, or a versioning change from weakening recovery. Deleting the stack is not the only way to remove its resources — an update does it too. Model the side doors for the services you actually permit; a generic deny-list cannot do that for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't deny your own recovery path.&lt;/strong&gt; I left bucket versioning out of the shipped deny-list on purpose: blocking it account-wide also removes &lt;em&gt;your&lt;/em&gt; ability to turn versioning on during an incident, and a control that throttles incident response is one that gets ripped out under pressure. Model that trade-off per account instead of defaulting to a blanket deny.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Protect the evidence, not only the logging switch.&lt;/strong&gt; Denying &lt;code&gt;cloudtrail:StopLogging&lt;/code&gt; is not enough if a principal can erase the CloudWatch Logs stream, remove the subscription filter, or delete the forwarding destination downstream. Keep an immutable or separately administered S3 copy when audit durability matters.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expect operational fallout.&lt;/strong&gt; The sample reserves &lt;code&gt;iam:PassRole&lt;/code&gt; for one deployment role and inline role-policy mutation for one incident-response role. Those are exceptions to specific deny statements, not account administrators: IAM must still scope what they can pass or modify, and their trust policies must tightly control who can assume them. The sharpest edge is KMS grants: denying grant creation stopped EFS and RDS from creating resources encrypted with a customer-managed key, and made DynamoDB fail with a literal explicit deny on &lt;code&gt;kms:CreateGrant&lt;/code&gt; — while EBS volumes and Secrets Manager sailed through. The Logs controls can break ordinary log provisioning and forwarding the same way. Test all of this in a disposable OU before rollout.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bound spend, not just permissions.&lt;/strong&gt; Nothing in this stack stops an agent from launching the largest instance type in a permitted Region. Add a Region restriction where it makes sense and a Budgets or cost-anomaly alarm — it is the cheapest blast-radius control here, and the one people forget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy via CloudFormation / IaC&lt;/strong&gt; so the SCP itself is versioned, reviewed, and rollback-able. Don't hand-edit the control plane from the console. One sequencing note: the blanket IAM denials apply to in-account IaC too, so stand the sandbox baseline up &lt;em&gt;before&lt;/em&gt; you attach this — or carve a deployment-role exception and treat that role as a privilege-escalation target in its own right, because anything it can do is also inherited by a compromised agent that reaches it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; the last layer — turning all of this into something you can actually query and alert on.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AWS Organizations — &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html" rel="noopener noreferrer"&gt;Service control policies (SCPs)&lt;/a&gt; (what SCPs don't restrict: management account, service-linked roles)&lt;/li&gt;
&lt;li&gt;AWS Organizations — &lt;a href="https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_rcps.html" rel="noopener noreferrer"&gt;Resource control policies (RCPs)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS Identity and Access Management — &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html" rel="noopener noreferrer"&gt;Permissions boundaries&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Your AWS role can't tell a human from an agent anymore, part 2: label every agent call with a User-Agent tag</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:54:23 +0000</pubDate>
      <link>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac</link>
      <guid>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-2-label-every-agent-call-with-a-gac</guid>
      <description>&lt;p&gt;Part 1 gave the agent an identity you control. This part makes its calls &lt;em&gt;visible&lt;/em&gt; — cheaply, and across a fleet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 2 — Label: make agent traffic visible in CloudTrail
&lt;/h2&gt;

&lt;p&gt;This one is cheap, mostly overlooked, and worth exactly what it is: supporting telemetry, not enforcement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;AWS_SDK_UA_APP_ID&lt;/code&gt;&lt;/strong&gt; is an officially supported setting that appends an application identifier to calls made by the AWS CLI and supported AWS SDKs. AWS documents it so customers can identify which application made a set of calls. That also makes it useful as a best-effort forensic tag for AI agents.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;AWS_SDK_UA_APP_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"ai-agent_mydepartment"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It also works as &lt;code&gt;sdk_ua_app_id&lt;/code&gt; in a shared &lt;code&gt;~/.aws/config&lt;/code&gt; profile. Either way, calls made by a supported client carrying it include &lt;code&gt;ai-agent_mydepartment&lt;/code&gt; in CloudTrail's &lt;code&gt;userAgent&lt;/code&gt; field — and it covers more than shell activity: if your agents run Claude Code against Bedrock, their own model calls are AWS API calls, so the inference traffic gets the tag too. (Checked: a Bedrock model call made with the app ID set shows up in CloudTrail's &lt;code&gt;Converse&lt;/code&gt; event carrying it. &lt;code&gt;Converse&lt;/code&gt; is logged as a management event, so it shows up even on a default trail.) Crude but effective:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Set it once at the machine level, not per tool.&lt;/strong&gt; Deploy it via your MDM / device management so every Claude Code, Codex, or OpenClaw process on a managed laptop inherits it. For Claude Code specifically, an &lt;code&gt;env&lt;/code&gt; block in &lt;code&gt;~/.claude/settings.json&lt;/code&gt; — or the project's &lt;code&gt;.claude/settings.json&lt;/code&gt;, or org-managed settings delivered by MDM — sets it for every session: &lt;code&gt;{"env": {"AWS_SDK_UA_APP_ID": "ai-agent_mydepartment"}}&lt;/code&gt;. The agent then has to actively &lt;em&gt;remove&lt;/em&gt; it, which is friction most tools won't bother with.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Know where the tag leaks.&lt;/strong&gt; Support is wide, not universal: legacy SDK majors (Go 1.x, Java 1.x, JavaScript 2.x) don't implement it, and non-SDK tooling carries its own User-Agent instead — Terraform's AWS provider, for example, uses &lt;code&gt;TF_APPEND_USER_AGENT&lt;/code&gt;. The app ID is also capped at 50 characters with a documented restriction on special characters. An untagged call means "this client doesn't participate", not "this was a human". Where traffic goes through AWS MCP servers instead, AWS sets &lt;code&gt;aws:CalledViaAWSMCP&lt;/code&gt; / &lt;code&gt;aws:ViaAWSMCPService&lt;/code&gt; from the request path — a signal the caller can't simply delete, though it covers that path only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat it as telemetry, not a control.&lt;/strong&gt; &lt;code&gt;AWS_SDK_UA_APP_ID&lt;/code&gt; is just an environment variable — an agent can &lt;code&gt;unset&lt;/code&gt; it or override it. AWS warns that the caller controls &lt;code&gt;aws:UserAgent&lt;/code&gt;, so it must not be used to prevent unauthorized direct requests. Use the tag to &lt;em&gt;observe&lt;/em&gt;, never to &lt;em&gt;authorize&lt;/em&gt;. A missing tag is a signal that something unusual happened, not proof that the request was human. I tested the control-shaped version while writing this: a permissions boundary that explicitly denies KMS reads when the bot's User-Agent appears works exactly as documented — until the caller removes one environment variable, or simply sends a different app ID. That is the whole argument, in one experiment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CloudTrail becomes filterable.&lt;/strong&gt; You can now answer "what is the agent doing?" with a simple query, instead of guessing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4z4kivhx8d7yhn9mwc8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl4z4kivhx8d7yhn9mwc8.png" alt="Where the tag lands, where it leaks, and why it is telemetry rather than a control" width="800" height="285"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Rolling the tag out with MDM
&lt;/h2&gt;

&lt;p&gt;The obvious objection: "we're not going to ask 200 developers to export an environment variable." Fair. But if the org already manages laptops, each major coding tool has a managed-configuration channel — and their support for this specific trick varies more than you'd expect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude Code&lt;/strong&gt; has the cleanest path. Managed settings (delivered by MDM, a &lt;code&gt;managed-settings.json&lt;/code&gt; in a system directory, or server-managed from the admin console) can't be overridden by user files, and they support an &lt;code&gt;env&lt;/code&gt; block:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"AWS_SDK_UA_APP_ID"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ai-agent_mydepartment"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because managed settings sit at the top of the precedence stack, every session gets the variable whether the user asks for it or not. If you're on a Claude Team/Enterprise plan and standardise on Claude Code, this is a ten-minute rollout.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Codex&lt;/strong&gt; gets there a different way. Its &lt;code&gt;shell_environment_policy&lt;/code&gt; controls which environment variables it passes to the commands it spawns, and it can inject values outright:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[shell_environment_policy]&lt;/span&gt;
&lt;span class="py"&gt;inherit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"core"&lt;/span&gt;
&lt;span class="py"&gt;set&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="py"&gt;AWS_SDK_UA_APP_ID&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
  &lt;span class="s"&gt;"ai-agent_mydepartment"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That config can be delivered as managed configuration — on macOS via an MDM profile in the &lt;code&gt;com.openai.codex&lt;/code&gt; preference domain (&lt;code&gt;config_toml_base64&lt;/code&gt;), on Linux/macOS via &lt;code&gt;/etc/codex/managed_config.toml&lt;/code&gt;, or as cloud-managed requirements from the workspace. One honest caveat: managed defaults are &lt;em&gt;defaults&lt;/em&gt; — a user can change them mid-session, and the client reapplies them next launch. (Enforced policy lives in &lt;code&gt;requirements.toml&lt;/code&gt;, which governs approval/sandbox settings rather than env vars.) So it's a strong default, not a lock.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cursor&lt;/strong&gt; is the awkward one. It supports MDM policies (allowed team IDs, extensions, update mode, workspace trust) and you can push a managed &lt;code&gt;~/.cursor/permissions.json&lt;/code&gt; to control terminal and MCP allowlists — but the policy file takes static JSON only, and its docs explicitly answer "no" to environment variables in policy. There's no documented managed-env mechanism for the agent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GitHub Copilot&lt;/strong&gt; sits in between: enterprise managed settings exist (&lt;code&gt;managed-settings.json&lt;/code&gt; in a &lt;code&gt;.github-private&lt;/code&gt; repo, or native MDM delivery via registry/macOS managed preferences), but they're aimed at AI settings like model selection — not injecting environment variables into the commands the agent runs.&lt;/p&gt;

&lt;p&gt;So, to answer the obvious question: &lt;strong&gt;if your company standardises on one coding plan, this is genuinely achievable&lt;/strong&gt; — Claude Code and Codex both have documented, MDM-deliverable paths to get the tag into the agent's calls. On a mixed fleet you do it tool by tool, and on Cursor/Copilot you fall back to setting the variable at the OS/shell level and accepting it won't be enforced per tool. And even where it is enforced, remember what it buys you: better telemetry. The tag is still caller-controlled, and the experiment above is the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading the trail
&lt;/h2&gt;

&lt;p&gt;Example — find all agent-originated calls in the last hour:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;START_MS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt; +%s&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="m"&gt;3600&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;1000&lt;/span&gt; &lt;span class="k"&gt;))&lt;/span&gt;
aws logs filter-log-events &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--log-group-name&lt;/span&gt; &lt;span class="s2"&gt;"CloudTrail/DefaultLogGroup"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--filter-pattern&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="s1"&gt;'{ $.userAgent = "*ai-agent_mydepartment*" }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--start-time&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$START_MS&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the JSON pattern rather than a bare quoted phrase. The quoted form matches the string anywhere in the event — &lt;code&gt;requestParameters&lt;/code&gt; included — while the &lt;code&gt;$.userAgent&lt;/code&gt; selector keeps it to the field that actually carries the tag. (Both forms were run against a test log group while writing this: on an event that echoed the tag in its parameters too, the quoted form returned a false positive and the JSON selector did not.) The SDK renders the app ID as &lt;code&gt;app/ai-agent_mydepartment&lt;/code&gt; inside the User-Agent string, so a substring match is what you want. And &lt;code&gt;filter-log-events&lt;/code&gt; paginates at 1 MB — a busy hour may need a &lt;code&gt;nextToken&lt;/code&gt; loop before you trust the count. That command assumes your trail is configured to deliver to a CloudWatch Logs group; use your actual log group name. For fleet-wide queries, an Athena table over the CloudTrail bucket is a better fit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; the layer that holds when everything above fails — an SCP backstop that even an admin session can't override.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AWS SDKs and Tools — &lt;a href="https://docs.aws.amazon.com/sdkref/latest/guide/feature-appid.html" rel="noopener noreferrer"&gt;Application ID (&lt;code&gt;AWS_SDK_UA_APP_ID&lt;/code&gt;)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Amazon Bedrock — &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/logging-using-cloudtrail.html" rel="noopener noreferrer"&gt;Log API calls using AWS CloudTrail&lt;/a&gt; (&lt;code&gt;Converse&lt;/code&gt; / &lt;code&gt;InvokeModel&lt;/code&gt; are management events)&lt;/li&gt;
&lt;li&gt;AWS Identity and Access Management — &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html#condition-keys-useragent" rel="noopener noreferrer"&gt;&lt;code&gt;aws:UserAgent&lt;/code&gt; condition key&lt;/a&gt; (warning: caller-provided, not for authorization)&lt;/li&gt;
&lt;li&gt;AWS Identity and Access Management — &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_condition-keys.html" rel="noopener noreferrer"&gt;Global condition keys&lt;/a&gt; (&lt;code&gt;aws:CalledViaAWSMCP&lt;/code&gt;, &lt;code&gt;aws:ViaAWSMCPService&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Claude Code — &lt;a href="https://code.claude.com/docs/en/settings" rel="noopener noreferrer"&gt;Settings&lt;/a&gt; (&lt;code&gt;env&lt;/code&gt; block, managed settings)&lt;/li&gt;
&lt;li&gt;Codex — &lt;a href="https://learn.chatgpt.com/docs/config-file/config-advanced" rel="noopener noreferrer"&gt;Advanced configuration&lt;/a&gt; (&lt;code&gt;shell_environment_policy&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Codex — &lt;a href="https://learn.chatgpt.com/docs/enterprise/managed-configuration" rel="noopener noreferrer"&gt;Managed configuration&lt;/a&gt; (MDM, &lt;code&gt;managed_config.toml&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Cursor — &lt;a href="https://cursor.com/docs/enterprise/deployment-patterns" rel="noopener noreferrer"&gt;Deployment patterns&lt;/a&gt; (MDM policies, &lt;code&gt;permissions.json&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;GitHub — &lt;a href="https://docs.github.com/en/copilot/reference/enterprise-administrators/enterprise-managed-settings" rel="noopener noreferrer"&gt;Enterprise managed settings for Copilot&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Terraform AWS Provider — &lt;a href="https://registry.terraform.io/providers/hashicorp/aws/latest/docs#custom-user-agent-information" rel="noopener noreferrer"&gt;Custom user-agent information&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS CLI — &lt;a href="https://docs.aws.amazon.com/cli/latest/reference/logs/filter-log-events.html" rel="noopener noreferrer"&gt;&lt;code&gt;filter-log-events&lt;/code&gt; time range&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Your AWS role can't tell a human from an agent anymore, part 1: the threat model and the identity problem</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Thu, 01 Oct 2026 19:54:14 +0000</pubDate>
      <link>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40</link>
      <guid>https://dev.to/aws-builders/your-aws-role-cant-tell-a-human-from-an-agent-anymore-part-1-the-threat-model-and-the-identity-k40</guid>
      <description>&lt;p&gt;Here's the honest thing nobody says at the "why hasn't AI coding taken over my org yet" keynote: the individual experience is genuinely great. Claude Code, Codex, OpenClaw — spin them up on a single machine and the productivity bump is real. You finish in an hour what used to take a day.&lt;/p&gt;

&lt;p&gt;The step that dies is &lt;strong&gt;enterprise adoption&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Not because the tools stop working — but because a company running a proper SDLC can't just log in and let an agent have the run of the place at 9am for the next eight hours. And at the other extreme, you have the traditional banks and FSI shops that respond by locking everything behind a remote browser or a heavy VM sandbox — which works, but neuters the whole point of agentic tooling. The agent becomes a glorified browser that can't read your repo, can't run your tests, can't look at your infra. Useless for autonomous troubleshooting.&lt;/p&gt;

&lt;p&gt;This series is written for the security or AI platform team on the receiving end of that request — the ones asked to hand out real account access to agents without betting the account on perfect model behavior. Over five posts I'll lay out four cheap layers that let agents do useful work inside a real AWS account, without either locking everything behind a remote browser or handing over the kingdom. Everything in this series was tested in a live account — I'll say so where it was.&lt;/p&gt;

&lt;p&gt;This is not a series about agent harnesses, prompt engineering, or making a model smarter. It is about &lt;em&gt;defense&lt;/em&gt;: how the security and infra team learns to protect an account when the same cloud identity can be wielded by a human &lt;em&gt;or&lt;/em&gt; a machine. That is a genuinely new problem. For decades a principal was a person, and the only distinction IAM had to make was privilege level — senior engineer versus a fresh graduate. Same species, different permissions. Now the identity behind a single call can be you, or an agent running as you, and traditional RBAC has no word for that split.&lt;/p&gt;

&lt;p&gt;There's a middle path. It isn't one silver bullet — it is a set of cheap layers that let agents do useful work inside a real cloud account without betting the account on perfect model behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 0 — The honest threat model
&lt;/h2&gt;

&lt;p&gt;Before the tech, get the framing right. The question is never "can I trust the agent?". It's "what can the agent do even when I &lt;em&gt;can't&lt;/em&gt; trust it?"&lt;/p&gt;

&lt;p&gt;An AI agent on a developer laptop is, from the cloud's point of view, an extremely enthusiastic intern with a keyboard and your AWS session. It's not malicious — but it's also not careful. It guesses. It retries. It cleans up the wrong thing. And — critically — poisoned content will try to make &lt;em&gt;it&lt;/em&gt; do things. Prompt injection into agentic CI is a real, growing attack surface: once an agent has write access, a crafted README or error message is a privilege escalation.&lt;/p&gt;

&lt;p&gt;So the design principle is: &lt;strong&gt;defense in depth, where no single layer is trusted to save you.&lt;/strong&gt; Every layer stops a different failure mode, and they should be cheap enough that you'll actually maintain them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk690imou4mdkhlkpwj7v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk690imou4mdkhlkpwj7v.png" alt="The identity chain: SSO to permission-set role to agent role with SourceIdentity, capped at one hour by role chaining" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Layer 1 — Identity: stop handing out the long-lived keys
&lt;/h2&gt;

&lt;p&gt;The most common mistake is the same one we made before AI agents existed, just accelerated: a long-lived &lt;code&gt;aws_access_key_id&lt;/code&gt; sitting in &lt;code&gt;~/.aws/credentials&lt;/code&gt;, shared across the team, valid for months, with admin attached.&lt;/p&gt;

&lt;p&gt;AI agents make this a &lt;em&gt;single point of catastrophic failure&lt;/em&gt; — because they'll happily &lt;code&gt;export&lt;/code&gt; it, paste it into a plan, or base64 it into a log. You cannot lose a key you never create.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What to do:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use &lt;strong&gt;AWS IAM Identity Center&lt;/strong&gt; (&lt;code&gt;aws sso login&lt;/code&gt;) with temporary sessions instead of static keys. The credentials stop working when the configured session expires.&lt;/li&gt;
&lt;li&gt;Scope the &lt;em&gt;session&lt;/em&gt; the agent runs under — ideally a &lt;strong&gt;separate role with least privilege&lt;/strong&gt;, not your own admin role.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cap the agent role's maximum session duration.&lt;/strong&gt; An hour is enough for a work session and short enough that a session copied off the host expires quickly; don't raise it toward the 12-hour ceiling just because you can.&lt;/li&gt;
&lt;li&gt;Give the agent its &lt;strong&gt;own principal, not a shared human role.&lt;/strong&gt; In the role's trust policy, allow &lt;code&gt;sts:SetSourceIdentity&lt;/code&gt; and require the assume request to carry &lt;code&gt;sts:SourceIdentity&lt;/code&gt;. Once the session exists, downstream requests expose it as &lt;code&gt;aws:SourceIdentity&lt;/code&gt;, and CloudTrail records it. Session tags are separate: they require &lt;code&gt;sts:TagSession&lt;/code&gt;, and only tags marked transitive survive role chaining.&lt;/li&gt;
&lt;li&gt;If developers enter through an &lt;strong&gt;AWS IAM Identity Center permission set&lt;/strong&gt;, have that SSO session assume a dedicated agent role you control. The generated permission-set role is the wrong place to build custom trust-policy machinery. That second assumption is role chaining, so the agent session is capped at one hour; make sure the credential provider can refresh it.&lt;/li&gt;
&lt;li&gt;Never put real credentials in the repo, CI variables, or &lt;code&gt;.env&lt;/code&gt; that an agent can read. The agent reads everything on the machine, remember.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A trust policy that does the source-identity part looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"AWS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:iam::111122223333:role/DeveloperTeamRole"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:SetSourceIdentity"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"StringLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"sts:SourceIdentity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-code-*"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sts:SetSourceIdentity&lt;/code&gt; must be allowed on both sides — in the trust policy and in the permissions policy of the principal doing the assuming — or the assume call fails. And a source identity the agent's own wrapper chose is only as good as this condition: the trust policy is what makes it meaningful.&lt;/p&gt;

&lt;p&gt;This removes the leaked &lt;em&gt;long-lived&lt;/em&gt; key incident class, not credential theft. An agent can still copy a working session off the host. What you gain is a short, attributable blast window. If you need to cut live sessions, use IAM's &lt;strong&gt;Revoke active sessions&lt;/strong&gt; operation: it attaches an &lt;code&gt;AWSRevokeOlderSessions&lt;/code&gt; inline policy using &lt;code&gt;aws:TokenIssueTime&lt;/code&gt;. That is per-role, which is another reason the agent needs its own role — and note it only kills sessions issued &lt;em&gt;before&lt;/em&gt; the timestamp, so if the assume path is still alive the agent just re-authenticates. Revocation needs &lt;code&gt;iam:PutRolePolicy&lt;/code&gt;, and removing the revocation policy needs &lt;code&gt;iam:DeleteRolePolicy&lt;/code&gt;; the SCP in part 3 reserves those two calls for one protected incident-response role.&lt;/p&gt;

&lt;p&gt;The safest useful pattern is broad &lt;strong&gt;metadata&lt;/strong&gt; read access for troubleshooting, paired with narrowly scoped writes. Carve out the secret-bearing reads — pulling a secret value from Secrets Manager, decrypting SSM parameters, KMS decrypts, object reads on data buckets — none of these belong in a general troubleshooting role. Whatever the agent reads can leave the account in its model context.&lt;/p&gt;

&lt;p&gt;Do not stop at the obvious read APIs. Interactive workload access can reach the same data indirectly when the workload role can read it: starting an SSM shell or sending a command, exec-ing into an ECS task, invoking a Lambda function, pushing an SSH key to an instance, or connecting to RDS with IAM auth. Each deserves an explicit, resource-scoped decision. Deny secret reads and these indirect paths by default in the &lt;strong&gt;agent role's identity policy&lt;/strong&gt;, then allow only the exact troubleshooting workflows you intend — those decisions belong to the role, not the account-wide SCP, unless nobody in the whole sandbox account needs the capability.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Next up:&lt;/strong&gt; the cheapest layer of all — making every agent call visible in CloudTrail, and how to roll that tag out across a fleet with MDM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;AWS Identity and Access Management — &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_credentials_temp_control-access_monitor.html" rel="noopener noreferrer"&gt;Monitor and control actions taken with assumed roles&lt;/a&gt; (source identity)&lt;/li&gt;
&lt;li&gt;AWS Identity and Access Management — &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_revoke-sessions.html" rel="noopener noreferrer"&gt;Revoke IAM role sessions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;AWS Identity and Access Management — &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles.html#iam-term-role-chaining" rel="noopener noreferrer"&gt;Role chaining&lt;/a&gt; (one-hour session limit)&lt;/li&gt;
&lt;li&gt;AWS Identity and Access Management — &lt;a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_session-tags.html" rel="noopener noreferrer"&gt;Pass session tags in AWS STS&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>ai</category>
      <category>devsecops</category>
    </item>
    <item>
      <title>Google Custom Search Shuts Down in 2027 — I Replaced It with Amazon Bedrock AgentCore Web Search</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Sat, 12 Sep 2026 16:22:41 +0000</pubDate>
      <link>https://dev.to/aws-builders/google-custom-search-shuts-down-in-2027-i-replaced-it-with-amazon-bedrock-agentcore-web-search-1ffj</link>
      <guid>https://dev.to/aws-builders/google-custom-search-shuts-down-in-2027-i-replaced-it-with-amazon-bedrock-agentcore-web-search-1ffj</guid>
      <description>&lt;p&gt;Google Custom Search JSON API is closed to new customers. Existing customers have until &lt;strong&gt;January 1, 2027&lt;/strong&gt; to move to another service. Google calls Vertex AI Search a favourable alternative for searches over up to 50 domains; for full-web search, it asks customers to contact it. &lt;a href="https://developers.google.com/custom-search/v1/overview" rel="noopener noreferrer"&gt;Google’s overview&lt;/a&gt; is specific to the JSON API, not every Google Custom Search product.&lt;/p&gt;

&lt;p&gt;I started implementing GenAI-enabled software from early 2023, back then developers have already recommended Google's CSE API as a great source for any AI agents to perform question and answer that requires up to date information. If Google were not making this deprecation, I would have been very happy to continue with my current setup.&lt;/p&gt;

&lt;p&gt;For an existing paid Custom Search JSON API customer, the published rate is $5 per 1,000 queries after the 100-per-day free tier. Amazon Bedrock AgentCore Web Search is $7 per 1,000 queries. I chose the higher per-query price because the search tool could live behind an existing AWS account and its access controls, rather than introducing another vendor and review process.&lt;/p&gt;

&lt;p&gt;I considered Brave and Exa too, but this is not a search-quality benchmark. I did not measure matched-query relevance, latency, or like-for-like total cost. This is a field report on using AgentCore Gateway as an AWS-native authorisation boundary.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3bttbuivfihus4smueq0.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3bttbuivfihus4smueq0.png" alt="Comparison of web-search options: AgentCore costs more per query than the outgoing Google API, but was chosen for AWS-native billing and access controls." width="800" height="1227"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The useful lessons were not the connector’s happy path. They were version pinning, validating security controls, and debugging each layer separately.&lt;/p&gt;

&lt;h2&gt;
  
  
  What AgentCore Web Search is
&lt;/h2&gt;

&lt;p&gt;Amazon describes Web Search Tool as a managed, MCP-compliant connector for AgentCore Gateway. It is currently available in &lt;code&gt;us-east-1&lt;/code&gt;, &lt;code&gt;eu-west-1&lt;/code&gt;, and &lt;code&gt;ap-northeast-1&lt;/code&gt;, and costs &lt;strong&gt;$7 per 1,000 queries&lt;/strong&gt;. &lt;a href="https://aws.amazon.com/bedrock/agentcore/pricing" rel="noopener noreferrer"&gt;AWS pricing&lt;/a&gt; and the &lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-connector-web-search-tool.html" rel="noopener noreferrer"&gt;connector documentation&lt;/a&gt; are the source of those figures.&lt;/p&gt;

&lt;p&gt;The connector removes third-party search API keys and provider-specific result parsing. It does not remove the need to configure IAM, a gateway, and an MCP client.&lt;/p&gt;

&lt;p&gt;The tool accepts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;query&lt;/code&gt;: up to 200 characters&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;maxResults&lt;/code&gt;: 1–25 results, default 10&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;filters&lt;/code&gt;: domain and publication-date filters when the target uses connector version &lt;code&gt;1.2.0&lt;/code&gt; or later&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AWS says the index is Amazon-operated, spans tens of billions of documents, refreshes continuously, and keeps queries within AWS infrastructure. Those are service claims, not findings I independently verified. AWS’s acceptable-use terms also require you to retain and display source citations and links when surfacing search results to end users.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjjj9np7l318tdgp2g04.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnjjj9np7l318tdgp2g04.png" alt="Claude Code calls a local SigV4 proxy over stdio; the proxy calls an AgentCore Gateway, which invokes the AWS-managed Web Search connector." width="800" height="1405"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Create the gateway and connector
&lt;/h2&gt;

&lt;p&gt;Keep two identities distinct:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;gateway service role&lt;/strong&gt; is assumed by AgentCore to invoke the Web Search connector.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;caller identity&lt;/strong&gt;—the AWS credentials on the Pi or another client—needs its own permission to invoke the specific gateway.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For Web Search, the gateway service role needs these two actions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"InvokeGateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock-agentcore:InvokeGateway"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock-agentcore:us-east-1:111122223333:gateway/*"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Sid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"InvokeWebSearch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock-agentcore:InvokeWebSearch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock-agentcore:us-east-1:aws:tool/web-search.v1"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scope the trust policy to gateway ARNs, not every AgentCore resource in the account:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"Statement"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Effect"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Allow"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Principal"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"Service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock-agentcore.amazonaws.com"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sts:AssumeRole"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"Condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"StringEquals"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:SourceAccount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"111122223333"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"ArnLike"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"aws:SourceArn"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arn:aws:bedrock-agentcore:us-east-1:111122223333:gateway/*"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Create an MCP gateway with IAM authorisation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws bedrock-agentcore-control create-gateway &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; websearch-gw &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role-arn&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ROLE_ARN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--protocol-type&lt;/span&gt; MCP &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--authorizer-type&lt;/span&gt; AWS_IAM &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Choose the inbound authorisation model deliberately. The gateway service role is not a substitute for the caller’s own identity policy: grant the Pi’s AWS principal &lt;code&gt;bedrock-agentcore:InvokeGateway&lt;/code&gt; on this gateway ARN.&lt;/p&gt;

&lt;p&gt;Then add the connector target. Pinning &lt;code&gt;1.2.0&lt;/code&gt; makes the filters described above part of the configuration instead of depending on a future default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_gateway_target&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;gatewayIdentifier&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;gateway_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web-search-tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;targetConfiguration&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connector&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connectorId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;web-search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.2.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;configurations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
                    &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;WebSearch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;parameterValues&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}}&lt;/span&gt;
                &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;credentialProviderConfigurations&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;credentialProviderType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GATEWAY_IAM_ROLE&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for the target to reach &lt;code&gt;READY&lt;/code&gt; before diagnosing &lt;code&gt;tools/list&lt;/code&gt;. A target still being created can look like an empty tool list.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the gateway before adding an editor
&lt;/h2&gt;

&lt;p&gt;Before adding Claude Code or a proxy, I sent raw SigV4-signed MCP requests directly to the gateway:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;initialize
notifications/initialized
tools/list
tools/call
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This isolates the gateway, IAM, connector, and MCP transport from the editor integration. Three details matter:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Send an &lt;code&gt;Accept&lt;/code&gt; header that includes both &lt;code&gt;application/json&lt;/code&gt; and &lt;code&gt;text/event-stream&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Handle either a JSON response or an SSE stream.&lt;/li&gt;
&lt;li&gt;If &lt;code&gt;initialize&lt;/code&gt; returns &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, echo it on later requests; do not assume every server issues one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Web Search result appears in an MCP &lt;code&gt;content&lt;/code&gt; block, with the useful payload represented as JSON text. Parse the transport envelope first, then the search-result payload.&lt;/p&gt;

&lt;p&gt;AWS currently says its index reflects new and changed content within minutes. My own same-day result was only a sanity check, not a measurement of refresh behaviour. If freshness matters, test the queries that matter to your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Connect Claude Code
&lt;/h2&gt;

&lt;p&gt;Claude Code does not natively sign the gateway’s SigV4 requests, so I used AWS’s MCP proxy as a local stdio bridge:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"awswebsearch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"uvx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"mcp-proxy-for-aws-cli@latest"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"${GATEWAY_URL}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--service"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock-agentcore"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="s2"&gt;"--region"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use the CLI distribution, &lt;code&gt;mcp-proxy-for-aws-cli&lt;/code&gt;, with &lt;code&gt;uvx&lt;/code&gt;; the unsuffixed package is the library distribution. Claude Code expands &lt;code&gt;${VAR}&lt;/code&gt; syntax in &lt;code&gt;.mcp.json&lt;/code&gt;, so &lt;code&gt;GATEWAY_URL&lt;/code&gt; can stay outside version control. A client that does not support configuration expansion needs the literal gateway URL instead.&lt;/p&gt;

&lt;p&gt;The final check should make a real search and return a source URL—not merely list the tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three traps worth knowing
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. &lt;code&gt;--read-only&lt;/code&gt; can hide Web Search
&lt;/h3&gt;

&lt;p&gt;The proxy’s &lt;code&gt;--read-only&lt;/code&gt; option keeps only tools that declare &lt;code&gt;readOnlyHint=true&lt;/code&gt;. Web Search did not advertise that annotation in my test, so the proxy hid it without a warning. If a tool disappears, compare &lt;code&gt;tools/list&lt;/code&gt; with and without the flag before changing IAM.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. An old CLI or SDK can reject &lt;code&gt;connector&lt;/code&gt; locally
&lt;/h3&gt;

&lt;p&gt;I hit an older AWS CLI service model that did not know the &lt;code&gt;connector&lt;/code&gt; member and rejected the request before it reached AWS. The symptom is an “Unknown parameter” validation error and no CloudTrail event. Upgrade the CLI or SDK before investigating IAM; current AWS documentation includes CLI support for this target type.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Debug from the bottom up
&lt;/h3&gt;

&lt;p&gt;Do not change several layers at once. Test in this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Get a basic model response from a clean client configuration.&lt;/li&gt;
&lt;li&gt;Call the gateway with raw SigV4-signed MCP requests.&lt;/li&gt;
&lt;li&gt;Confirm that the proxy exposes the expected tool.&lt;/li&gt;
&lt;li&gt;Run one real search through the editor.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That sequence makes a failure actionable: model access, gateway/IAM, proxy configuration, or end-to-end tool use.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test resource policies, do not just attach them
&lt;/h2&gt;

&lt;p&gt;AgentCore supports resource-based policies on gateways. I applied a temporary explicit &lt;code&gt;Deny&lt;/code&gt; for my public egress IP, confirmed that the caller received HTTP 403, and then removed the policy. That was more useful than merely seeing an accepted policy document.&lt;/p&gt;

&lt;p&gt;Keep these rules explicit:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Resource&lt;/code&gt; must be the exact ARN of the gateway; &lt;code&gt;*&lt;/code&gt; is not valid here.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;Deny&lt;/code&gt; with &lt;code&gt;IpAddress&lt;/code&gt; is a blocklist. A deny-based IP allowlist uses &lt;code&gt;NotIpAddress&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;SigV4 policies name concrete AWS principals; OAuth policies use &lt;code&gt;"Principal": "*"&lt;/code&gt; and narrow access with conditions.&lt;/li&gt;
&lt;li&gt;IP conditions see the public egress address, not an overlay-network address, and residential addresses can change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Establish principal-based access first, add IP restrictions second, and automate rollback for deliberate-deny tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost: two meters, one material charge
&lt;/h2&gt;

&lt;p&gt;Web Search queries and Gateway operations are metered separately. AWS lists Web Search at $7 per 1,000 queries and Gateway operations—such as listing tools, invoking tools, and pings—at $0.005 per 1,000 invocations.&lt;/p&gt;

&lt;p&gt;The second meter is worth recognising in Cost Explorer, but it is usually small. Even four Gateway operations per search add $0.02 per 1,000 searches beside the $7.00 search charge. Measure the traffic pattern, but do not mistake a chatty MCP client for a meaningful change in search cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teardown
&lt;/h2&gt;

&lt;p&gt;Delete resources in this order:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;targets -&amp;gt; resource policy -&amp;gt; gateway -&amp;gt; role policy -&amp;gt; role
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait for deletion to complete, then remove the manually created IAM policy and role. That removes the AgentCore resources; audit records, retained logs, local MCP configuration, and package caches may still remain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;Choose AgentCore Web Search when the requirement is specifically an AWS-native search tool behind an AgentCore Gateway, with SigV4 or OAuth access control and resource-policy enforcement. In that situation, the higher query price buys an operational boundary, not demonstrated search-quality superiority.&lt;/p&gt;

&lt;p&gt;Do not choose it solely because an application needs web search. I did not benchmark it against Brave or Exa on equivalent workloads. Without a concrete need for the AWS authorisation and governance model, the extra integration surface is hard to justify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.google.com/custom-search/v1/overview" rel="noopener noreferrer"&gt;Google Custom Search JSON API overview&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aws.amazon.com/bedrock/agentcore/pricing" rel="noopener noreferrer"&gt;Amazon Bedrock AgentCore pricing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-target-connector-web-search-tool.html" rel="noopener noreferrer"&gt;Web Search Tool documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/gateway-add-target-api-target-config.html" rel="noopener noreferrer"&gt;Gateway target configuration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/resource-based-policies.html" rel="noopener noreferrer"&gt;AgentCore resource-based policies&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-03-26/basic/transports" rel="noopener noreferrer"&gt;MCP Streamable HTTP transport&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/aws/mcp-proxy-for-aws" rel="noopener noreferrer"&gt;MCP Proxy for AWS&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://code.claude.com/docs/en/mcp" rel="noopener noreferrer"&gt;Claude Code MCP configuration&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>ai</category>
      <category>mcp</category>
      <category>security</category>
    </item>
    <item>
      <title>Why I Split-Tunnel My VPN for AI Services — and Let Cloudflare's Application Library Pick the Domains</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Fri, 19 Jun 2026 10:00:00 +0000</pubDate>
      <link>https://dev.to/gabrielkoo/i-used-cloudflares-app-library-to-map-every-ai-services-domains-and-why-networking-still-matters-2and</link>
      <guid>https://dev.to/gabrielkoo/i-used-cloudflares-app-library-to-map-every-ai-services-domains-and-why-networking-still-matters-2and</guid>
      <description>&lt;p&gt;I maintain a small set of &lt;strong&gt;open-source GitHub repos&lt;/strong&gt; that hold split-tunnel VPN configs for reaching AI services — &lt;a href="https://github.com/gabrielkoo/tailscale-config-for-ai-services" rel="noopener noreferrer"&gt;Tailscale app connectors&lt;/a&gt;, &lt;a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt;, and &lt;a href="https://github.com/gabrielkoo/openvpn-configs-for-ai-services" rel="noopener noreferrer"&gt;OpenVPN&lt;/a&gt;. The single hardest part of maintaining them isn't the VPN config. It's answering one deceptively boring question:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Which domains does this AI platform actually use?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This post is about a shortcut I lean on — Cloudflare's Application Library, queried over its REST API — and a broader point I keep coming back to: even in an era where AI can scaffold most of your code and config for you, &lt;strong&gt;networking fundamentals are still worth learning&lt;/strong&gt;. Not because the AI can't write the config, but because the difference between a clean setup and one that gets your account flagged comes down to a judgment call — &lt;em&gt;what you route and what you deliberately don't&lt;/em&gt; — and that judgment is exactly the part an AI can't make for you without understanding your intent.&lt;/p&gt;

&lt;p&gt;(One caveat before we start: this is for routing traffic you're already entitled to — not for evading restrictions that legitimately apply to you. Full legitimate-use note at the end.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem: a "domain list" is never just one domain
&lt;/h2&gt;

&lt;p&gt;When you want to route only one app through a VPN, you need the set of hostnames (or IPs) that app actually talks to. That sounds trivial until you open DevTools on a modern web app and watch it fan out to twenty different domains: the main API, an auth provider, a CDN for static assets, telemetry, a fraud-detection pixel, a feature-flag service.&lt;/p&gt;

&lt;p&gt;Route too little and the app half-works (login spins forever, streaming responses stall). Route too much — say, the bare &lt;code&gt;*.cloudflarestorage.com&lt;/code&gt; wildcard — and you scoop up huge amounts of &lt;em&gt;unrelated&lt;/em&gt; traffic, which defeats the entire point of a split tunnel and can even get your exit node rate-limited.&lt;/p&gt;

&lt;p&gt;So you need the &lt;strong&gt;narrowest accurate&lt;/strong&gt; set of hostnames. Where do you get it?&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm22l6vo9m4au2yflfhmt.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm22l6vo9m4au2yflfhmt.jpg" alt="One app fans out to many hostnames — a single ChatGPT app talking to api.openai.com, auth0.openai.com, cdn.oaistatic.com, oaiusercontent.com and more" width="800" height="800"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortcut: Cloudflare's Application Library
&lt;/h2&gt;

&lt;p&gt;Cloudflare's Zero Trust product ships an &lt;a href="https://developers.cloudflare.com/cloudflare-one/team-and-resources/app-library" rel="noopener noreferrer"&gt;&lt;strong&gt;Application Library&lt;/strong&gt;&lt;/a&gt; — a catalog of well-known SaaS apps, each annotated with the hostnames Cloudflare has observed it using. It's meant for admins building Access policies, but it doubles as a fantastic &lt;em&gt;domain-discovery&lt;/em&gt; tool. Someone at Cloudflare already did the tedious traffic-watching for ChatGPT, Claude, and friends.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl42dra8quhm9ekbk3dy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyl42dra8quhm9ekbk3dy.jpg" alt="Stop sniffing traffic by hand — the Cloudflare Application Library hands you the hostnames so you don't have to map them manually" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;You can browse it in the dashboard under &lt;strong&gt;Zero Trust → Team &amp;amp; Resources → Application Library&lt;/strong&gt;. But I don't want to click through a UI every time a provider quietly adds a domain — I want it in code. So I query the &lt;a href="https://developers.cloudflare.com/api/" rel="noopener noreferrer"&gt;REST API&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Here's the helper I use to pull ChatGPT's hostnames. Pure stdlib, no SDK:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="n"&gt;CF_API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.cloudflare.com/client/v4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;cf_hostnames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;%s/accounts/%s/resource-library/applications?%s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;CF_API&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;account&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hostnames&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]))&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;SystemExit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CF: application %r not found (search=%r)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;app_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;search&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few things worth calling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint is &lt;code&gt;accounts/{account_id}/resource-library/applications&lt;/code&gt;.&lt;/strong&gt; It takes a &lt;code&gt;search&lt;/code&gt; query and returns matching catalog entries, each with a &lt;code&gt;hostnames&lt;/code&gt; array. I match on the exact &lt;code&gt;name&lt;/code&gt; (e.g. &lt;code&gt;"ChatGPT"&lt;/code&gt;, &lt;code&gt;"Claude"&lt;/code&gt;) because a search can return several near-matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The token only needs read access&lt;/strong&gt; to the resource library. Scope it minimally — there's no reason this token should be able to change anything.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's deterministic and CI-friendly.&lt;/strong&gt; No browser automation, no scraping. That matters for the next step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You don't need a paid plan.&lt;/strong&gt; This lives in Cloudflare's Zero Trust product, and Zero Trust has a &lt;a href="https://blog.cloudflare.com/teams-plans/" rel="noopener noreferrer"&gt;&lt;strong&gt;free tier (up to 50 seats)&lt;/strong&gt;&lt;/a&gt; that's plenty for personal use. The Application Library and its REST API are available on that free plan — so the entire domain-discovery pipeline costs you nothing but an API token.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What the API actually returns
&lt;/h3&gt;

&lt;p&gt;Here's the real output for &lt;strong&gt;Claude&lt;/strong&gt; (&lt;code&gt;name == "Claude"&lt;/code&gt;) as of this writing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"application"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Claude"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hostnames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"a-api.anthropic.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"a-cdn.anthropic.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"anthropic.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"claude.ai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"claude.com"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Five hostnames, and notice they cover the things that actually matter: the web app (&lt;code&gt;claude.ai&lt;/code&gt;, &lt;code&gt;claude.com&lt;/code&gt;), the API (&lt;code&gt;a-api.anthropic.com&lt;/code&gt;), the static asset CDN (&lt;code&gt;a-cdn.anthropic.com&lt;/code&gt;), and the marketing/auth origin (&lt;code&gt;anthropic.com&lt;/code&gt;). That's the &lt;em&gt;narrow accurate set&lt;/em&gt; I was after — nothing extraneous to prune.&lt;/p&gt;

&lt;p&gt;And the same call for &lt;strong&gt;ChatGPT&lt;/strong&gt; (App ID &lt;code&gt;1199&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"application"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ChatGPT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hostnames"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"api.openai.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"auth.openai.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"auth0.openai.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"cdn.oaistatic.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"chat.openai.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"chatgpt.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"oaistatic.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"oaiusercontent.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"openai.com"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nine hostnames, and the list is more revealing than it looks. Miss the Auth0-backed &lt;code&gt;auth0.openai.com&lt;/code&gt; (still in the login path at time of writing) and sign-in silently hangs; miss &lt;code&gt;oaiusercontent.com&lt;/code&gt; and uploads/generated files break. Try assembling that from DevTools by hand and you'll miss the auth host you only hit on first login — then spend an afternoon debugging a broken login flow.&lt;/p&gt;

&lt;p&gt;Cloudflare already did the traffic-watching.&lt;/p&gt;

&lt;h2&gt;
  
  
  From hostnames to a routable config
&lt;/h2&gt;

&lt;p&gt;Hostnames are perfect for &lt;strong&gt;&lt;a href="https://tailscale.com/kb/1281/app-connectors" rel="noopener noreferrer"&gt;Tailscale app connectors&lt;/a&gt;&lt;/strong&gt;, which match on the domain name directly — Tailscale handles the DNS-to-IP mapping for you, so the config keeps working even when the provider rotates IPs.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;WireGuard and OpenVPN&lt;/strong&gt; — which you'd reach for when you need router-level enforcement, or you're on a client where Tailscale isn't an option — you can't route by hostname; those tunnels route by IP. So the pipeline becomes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pull hostnames&lt;/strong&gt; from the Cloudflare Application Library (the function above).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resolve each to A + AAAA records&lt;/strong&gt; over DNS-over-HTTPS, so the result doesn't depend on whatever resolver the CI runner happens to use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Aggregate&lt;/strong&gt; the addresses to CIDR blocks, then collapse overlapping ranges. I default to &lt;code&gt;/24&lt;/code&gt; (v4) and &lt;code&gt;/48&lt;/code&gt; (v6), but understand this is a &lt;em&gt;heuristic&lt;/em&gt;, not precision: expanding one resolved IP to a &lt;code&gt;/24&lt;/code&gt; also routes the other 255 addresses on that edge node, some of which belong to unrelated tenants. It's a deliberate trade — narrower (&lt;code&gt;/32&lt;/code&gt;, &lt;code&gt;/28&lt;/code&gt;) is more precise but churns more often; wider (&lt;code&gt;/20&lt;/code&gt;, a supernet) means fewer entries but more collateral traffic. Pick the prefix that matches how much drift and collateral you can tolerate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Union with a static "floor"&lt;/strong&gt; for providers that publish a stable, authoritative prefix — for Anthropic that's &lt;code&gt;160.79.104.0/21&lt;/code&gt; (their own AS399230), so a single resolved &lt;code&gt;/24&lt;/code&gt; never accidentally blackholes the rest of the block.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rewrite the config files&lt;/strong&gt; between marker comments, and let a &lt;a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/.github/workflows/update-ips.yml" rel="noopener noreferrer"&gt;scheduled GitHub Action&lt;/a&gt; open the change.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The whole thing is one stdlib Python script (&lt;a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/scripts/update_ips.py" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/update_ips.py&lt;/code&gt;&lt;/a&gt;) that runs on a cron schedule. The configs stay fresh without me touching them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F79tztmpjhmsw20z72r0x.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F79tztmpjhmsw20z72r0x.jpg" alt="The resolve-and-aggregate pipeline — scattered cloud hostnames funnel through resolve + aggregate into a single clean config" width="800" height="437"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;And here's where the two providers diverge in a way that proves the whole point. Run the resolve-and-aggregate step and &lt;strong&gt;ChatGPT&lt;/strong&gt; collapses to a pile of Cloudflare ranges:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# IPv4 (all shared Cloudflare anycast)
104.18.32.0/23
104.18.37.0/24
104.18.41.0/24
162.159.140.0/24
172.64.146.0/24
172.64.150.0/24
172.64.154.0/23
172.65.90.0/24
172.66.0.0/24

# IPv6 — 9 more Cloudflare /48s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every one of those is &lt;strong&gt;shared Cloudflare anycast&lt;/strong&gt; — &lt;a href="https://www.cloudflare.com/ips/" rel="noopener noreferrer"&gt;&lt;code&gt;104.18.x&lt;/code&gt;, &lt;code&gt;172.64.x&lt;/code&gt;, &lt;code&gt;172.66.x&lt;/code&gt; are Cloudflare's&lt;/a&gt;, not OpenAI's. Route them and you're routing a slice of Cloudflare's entire customer base, and the specific &lt;code&gt;/24&lt;/code&gt;s will drift as Cloudflare reshuffles. That's why ChatGPT-by-IP needs the scheduled refresh, and why it's genuinely better as a Tailscale &lt;em&gt;hostname&lt;/em&gt; connector.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Claude&lt;/strong&gt; collapses to something completely different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;34.36.57.0/24    ← Google Cloud LB (shared)
160.79.104.0/21  ← Anthropic's OWN block (AS399230)
2607:6bc0::/32   ← Anthropic's own IPv6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;160.79.104.0/21&lt;/code&gt; is Anthropic's own registered allocation — a &lt;code&gt;whois&lt;/code&gt;/RDAP lookup shows the block (160.79.104.0–160.79.111.255) registered directly to Anthropic, PBC, announced via AS399230. So I can route the whole &lt;code&gt;/21&lt;/code&gt; and trust &lt;em&gt;who&lt;/em&gt; it belongs to, even though it's 2,048 addresses — wider than Claude strictly needs today. That's the deliberate stability-over-precision trade for a block whose owner won't suddenly hand it to someone else. The &lt;code&gt;34.36.57.0/24&lt;/code&gt; is a &lt;a href="https://www.gstatic.com/ipranges/cloud.json" rel="noopener noreferrer"&gt;Google Cloud front-end&lt;/a&gt; I let the script re-resolve each run rather than hardcode, because it &lt;em&gt;is&lt;/em&gt; shared infrastructure that drifts. &lt;strong&gt;Same pipeline, two completely different risk profiles — and you only know which is which if you understand who owns the address space.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq85qtwpklr6algfogntm.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq85qtwpklr6algfogntm.jpg" alt="Same pipeline, two risk profiles — one resolve+aggregate funnel splits into ChatGPT on shared Cloudflare anycast that drifts, versus Claude on Anthropic's own stable 160.79.104.0/21 block" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is a networking lesson, not a VPN lesson
&lt;/h2&gt;

&lt;p&gt;Every step above is a networking decision, and getting them wrong has real consequences:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwgjqj4s5xvbsn9srhq7.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwwgjqj4s5xvbsn9srhq7.jpg" alt="Hostname routing vs IP routing — hostname routing follows the name and survives IP churn; IP routing pins addresses and breaks on drift" width="800" height="993"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hostname routing vs. IP routing is a fundamental tradeoff.&lt;/strong&gt; DNS/SNI-based routing (Tailscale) survives IP churn and cleanly isolates one app on a shared CDN. IP-CIDR routing (WireGuard/OpenVPN) is simpler and dependency-free but brittle — it breaks when addresses drift, and it &lt;em&gt;can't&lt;/em&gt; separate two services that share an anycast front end. Knowing which tool fits which provider saves hours.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared anycast CDNs are a trap.&lt;/strong&gt; &lt;code&gt;chatgpt.com&lt;/code&gt; sits behind Cloudflare's shared anycast — the same &lt;code&gt;/24&lt;/code&gt;s serve thousands of unrelated sites. Route the whole block and you've quietly tunneled a chunk of the internet. Route too narrowly and it breaks tomorrow. You have to &lt;em&gt;understand&lt;/em&gt; that the IP doesn't belong to OpenAI to make a sane call.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9mj1c2r64q3lyvfe26t.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg9mj1c2r64q3lyvfe26t.jpg" alt="Route a shared /24, scoop unrelated traffic — a net catches the one IP you wanted along with 254 strangers sharing the block" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Knowing who owns an IP block matters.&lt;/strong&gt; &lt;code&gt;160.79.104.0/21&lt;/code&gt; is Anthropic's own allocation (AS399230). That's a stable, safe thing to route wholesale. A &lt;code&gt;34.36.x&lt;/code&gt; Google Cloud load-balancer front-end in front of the same service is &lt;em&gt;not&lt;/em&gt; — it's shared infrastructure. A quick &lt;code&gt;whois&lt;/code&gt; or a look at the AS tells you which is which.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Split tunneling is precision, and precision is how you stay out of trouble.&lt;/strong&gt; This circles back to the disclaimer. The reason I route the &lt;em&gt;narrowest&lt;/em&gt; set of hostnames isn't tidiness — it's that the less unrelated traffic I push through an exit node, the less I look like abuse, and the less I disrupt the security-sensitive services (banking, corporate SSO) that legitimately &lt;em&gt;don't&lt;/em&gt; want to see me arriving from a VPN IP. Full-tunneling everything is both lazier and riskier.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this requires Cisco's CCNA certification. But it does require knowing what a routing table is, what a CIDR block means, what an autonomous system is, and why DNS resolution is a separate concern from packet routing. That foundation turns "I copied a VPN config off the internet and hope it works" into "I know exactly what traffic goes where, and why."&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare's Application Library is an underrated domain-discovery tool&lt;/strong&gt; — queryable over a simple REST endpoint, no scraping required.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick your routing primitive to match the provider:&lt;/strong&gt; hostname-based for shared CDNs and IP-churning services, IP-CIDR for providers on their own stable address space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scope narrowly on purpose.&lt;/strong&gt; It's better for performance, it keeps your other services unaffected, and it keeps you on the right side of a provider's abuse heuristics.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;And stay legitimate.&lt;/strong&gt; Use this for what you're already entitled to use — careful routing, continuity while traveling — not for getting around restrictions that apply to you.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want the runnable code, the three repos are linked at the top. The Cloudflare helper and the full resolve-and-aggregate pipeline live in &lt;a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services/blob/main/scripts/update_ips.py" rel="noopener noreferrer"&gt;&lt;code&gt;scripts/update_ips.py&lt;/code&gt;&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  One last thing: the legitimate-use note
&lt;/h2&gt;

&lt;p&gt;I parked this for the end so it didn't bog down the technical walkthrough, but it matters. This whole approach is for one specific, legitimate situation: &lt;strong&gt;your account and home region are permitted to use the service, and you want precise control over your own traffic.&lt;/strong&gt; Two concrete cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You want to route carefully — or deliberately NOT route — to avoid tripping a provider's fraud/abuse checks.&lt;/strong&gt; Many platforms run security engines (WAFs, fraud-detection, bot management) that get nervous when a logged-in session suddenly appears from a datacenter IP or a region that doesn't match your account history. This cuts both ways: the same is true of &lt;em&gt;other&lt;/em&gt; services you use — banks especially — which actively distrust traffic arriving from a VPN exit IP. Precision routing is as much about &lt;em&gt;keeping the wrong traffic off the tunnel&lt;/em&gt; as putting the right traffic on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You normally reside in a region that's allowed to use the service, but you're temporarily somewhere it isn't reachable&lt;/strong&gt; — a short work trip, a conference, a layover. You're a legitimate user of an unrestricted region who wants continuity of access you're already entitled to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What this is &lt;strong&gt;not&lt;/strong&gt; for: evading a restriction that legitimately applies to you. If your account or country of residence isn't permitted to use a service, a VPN config doesn't change that, and nothing here is an invitation to break a provider's Terms of Service or your local law. &lt;strong&gt;Read the ToS, respect it, and when in doubt don't.&lt;/strong&gt; I keep my configs scoped as narrowly as possible precisely &lt;em&gt;because&lt;/em&gt; the goal is to not look like abuse traffic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/cloudflare-one/team-and-resources/app-library" rel="noopener noreferrer"&gt;Cloudflare — Application Library (Cloudflare One docs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developers.cloudflare.com/api/" rel="noopener noreferrer"&gt;Cloudflare — REST API reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://tailscale.com/kb/1281/app-connectors" rel="noopener noreferrer"&gt;Tailscale — App connectors&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://www.cloudflare.com/ips/" rel="noopener noreferrer"&gt;Cloudflare — published IP ranges&lt;/a&gt; · &lt;a href="https://www.gstatic.com/ipranges/cloud.json" rel="noopener noreferrer"&gt;Google Cloud — published IP ranges&lt;/a&gt; · &lt;a href="https://rdap.arin.net/registry/ip/160.79.104.0" rel="noopener noreferrer"&gt;ARIN RDAP — &lt;code&gt;160.79.104.0/21&lt;/code&gt; (Anthropic, PBC)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Companion repos: &lt;a href="https://github.com/gabrielkoo/tailscale-config-for-ai-services" rel="noopener noreferrer"&gt;Tailscale&lt;/a&gt; · &lt;a href="https://github.com/gabrielkoo/wireguard-configs-for-ai-services" rel="noopener noreferrer"&gt;WireGuard&lt;/a&gt; · &lt;a href="https://github.com/gabrielkoo/openvpn-configs-for-ai-services" rel="noopener noreferrer"&gt;OpenVPN&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>networking</category>
      <category>cloudflare</category>
      <category>vpn</category>
      <category>ai</category>
    </item>
    <item>
      <title>Stop Putting API Keys in Your MCP Config: Per-User OAuth with Amazon Cognito + AWS Lambda</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Sun, 07 Jun 2026 15:00:00 +0000</pubDate>
      <link>https://dev.to/aws-builders/stop-putting-api-keys-in-mcpjson-per-user-oauth-with-amazon-cognito-aws-lambda-4h2i</link>
      <guid>https://dev.to/aws-builders/stop-putting-api-keys-in-mcpjson-per-user-oauth-with-amazon-cognito-aws-lambda-4h2i</guid>
      <description>&lt;p&gt;&lt;em&gt;The runnable companion to my AgentCon HK 2026 talk, &lt;a href="https://the-quantum-nargle.github.io/agentcon-2026-hk-slides/" rel="noopener noreferrer"&gt;"Empower Team-Wide Vibe Coding with LLM Gateway and Security-First MCPs."&lt;/a&gt; The talk argued per-user OAuth is what turns a shared god-token into safe, auditable agent access. This is the wiring — explained at the architecture level. The full &lt;code&gt;template.yaml&lt;/code&gt;, Lambda, and scripts live in the public repo linked at the end.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The gap nobody fills: &lt;em&gt;your&lt;/em&gt; identity in front of a shared-key API
&lt;/h2&gt;

&lt;p&gt;By 2026 most big SaaS shipped an official MCP server, and many even support OAuth. So "my tool has no MCP" and "my tool has no OAuth" are both fading problems — chasing vendors that lack one is a losing game; the list shrinks every week.&lt;/p&gt;

&lt;p&gt;Tavily — the search API I use here — is actually a good citizen: its remote MCP supports both a shared key in the URL (&lt;code&gt;https://mcp.tavily.com/mcp/?tavilyApiKey=&amp;lt;KEY&amp;gt;&lt;/code&gt;) &lt;em&gt;and&lt;/em&gt; an OAuth flow. So why wrap it at all? Because even a vendor's OAuth answers a different question than the one a security team is asking:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shared keys are the default, and cost is why.&lt;/strong&gt; Plenty of these tools price per seat. So the cheapest integration — the one teams actually ship — is one account's API key wired into a single shared MCP for the whole group. The instant that key is shared, per-user identity is gone: every call is the same caller, and "who ran this?" has no answer. Vendor OAuth only helps if everyone pays for their own seat, which is exactly the bill teams are dodging.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Claimed ≠ enforced.&lt;/strong&gt; Even when a tool &lt;em&gt;does&lt;/em&gt; attribute per user, it often rides on a value the client supplies — Tavily's &lt;code&gt;X-Human-Id&lt;/code&gt; header is exactly this. The client asserts it, so nothing stops a caller from sending someone else's; the attribution is a courtesy, not a control. Trustworthy attribution has to come from a token &lt;em&gt;your own&lt;/em&gt; gateway cryptographically verified, not a string the client typed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And here's the part that doesn't shrink at all: &lt;strong&gt;the upstreams that matter most to you will never ship OAuth.&lt;/strong&gt; Your internal billing API, the legacy claims system nobody wants to touch, the compliance-mandated third-party tool with a single team API key. For every one of those, the only auth is a shared key — shared by nature. And it's not only internal systems: plenty of public vendors are the same shape — Brave Search's own official MCP server, for instance, authenticates with a single &lt;code&gt;BRAVE_API_KEY&lt;/code&gt; and no OAuth at all. One key, the whole team behind it.&lt;/p&gt;

&lt;p&gt;That's the real, durable gap: &lt;strong&gt;no front door that authenticates callers as &lt;em&gt;your&lt;/em&gt; identity, scopes them, and audits them — before handing off to a shared-key upstream.&lt;/strong&gt; That's what this post builds. Tavily is just a free, public stand-in you can actually run; mentally swap it for your own shared-key API.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;💡 &lt;strong&gt;The one idea:&lt;/strong&gt; put a real OAuth 2.0 / OIDC authorization server bound to &lt;em&gt;your&lt;/em&gt; identity (Amazon Cognito) — running the PKCE and client-credentials flows the MCP spec expects — in front of a shared-key upstream, in a single Lambda. Each caller gets their own scoped, cryptographically-verified, audited identity. The shared key never leaves the server.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Want the 30-second version first? &lt;a href="https://gabrielkoo.github.io/tavily-oauth-mcp-wrapper/" rel="noopener noreferrer"&gt;Walk through the interactive demo&lt;/a&gt; — click through the whole OAuth flow, the scoped tool call, and the &lt;code&gt;403&lt;/code&gt; when a caller reaches past its scope.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;

&lt;p&gt;Two pictures tell the whole story. The shared-key path — one key for everyone, whether it sits in a URL or a config file:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frfx5k5a7luftlfqxbk58.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Frfx5k5a7luftlfqxbk58.png" alt="Shared-key access — three callers (two engineers and an agent) all funnel through one shared key, which then hits the upstream API. No per-user scope under your control, no audit you own, the key leaks into logs, and rotating it breaks the whole team." width="800" height="755"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With the wrapper, each caller arrives as themselves — verified against your own identity provider — and the key is locked away server-side:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgpta03o5232g2pzcu7p.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhgpta03o5232g2pzcu7p.png" alt="The OAuth wrapper — each caller logs in to its own session, hits an MCP server fronted by Amazon Cognito OAuth that enforces per-user scope and audit, and the shared key sits server-side (Secrets Manager / Parameter Store), reached only by the server before it calls the upstream. Scoped per identity, fully audited, key never leaves the server, and one session can be revoked without touching the team." width="800" height="858"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Three managed AWS pieces do the work, each with one clear job:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Amazon Cognito — the bouncer who issues the wristbands.&lt;/strong&gt; It's the OAuth 2.0 / OIDC authorization server (with PKCE — the piece OAuth 2.1 and the MCP spec lean on). A caller proves who they are &lt;em&gt;to Amazon Cognito&lt;/em&gt;, which hands back a short-lived signed token stamped with a scope (here, &lt;code&gt;tavily-mcp/search&lt;/code&gt;). It handles both kinds of caller from the same pool: a backend agent authenticates machine-to-machine, a human logs in through a hosted login page with PKCE. Crucially, Amazon Cognito &lt;em&gt;is&lt;/em&gt; your identity layer — swapping the demo's username/password for real corporate SSO (SAML, OIDC, or social logins like Google, Microsoft, Apple) is a configuration change, not a rebuild. The tokens, scopes, and everything downstream stay identical.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API Gateway HTTP API + its JWT authorizer — the wristband scanner at the door.&lt;/strong&gt; Every request to the MCP endpoint — a plain JSON-RPC call over HTTP POST (the Streamable HTTP transport; this wrapper uses request/response POSTs only, not the transport's optional SSE streaming channel) — hits API Gateway first. Its &lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/http-api-jwt-authorizer.html" rel="noopener noreferrer"&gt;built-in JWT authorizer&lt;/a&gt; — a native feature of the HTTP API (v2) flavour; the older REST API would need a Cognito or custom Lambda authorizer instead — checks the token's signature, issuer, and expiry &lt;em&gt;at the edge&lt;/em&gt;, before a single line of your code runs. No token, expired token, forged token: rejected with a 401 right there. This is pure authentication: &lt;em&gt;are you who the token says you are?&lt;/em&gt; Nothing reaches your logic until that passes. (One footgun worth flagging: Cognito access tokens carry no &lt;code&gt;aud&lt;/code&gt; claim, so the authorizer's audience must be set to the Cognito app client ID — a mismatch here is the most common source of silent 401s in this stack.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lambda — the room you're actually allowed into.&lt;/strong&gt; Once the token is valid, the Lambda does the &lt;em&gt;authorization&lt;/em&gt;: it re-reads the scope to confirm this identity may call this specific tool, writes an audit line tying the action to that caller, then — and only then — reaches into Secrets Manager for the shared upstream key and calls Tavily. The key lives only inside this function's execution role; it is never sent back to the client.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu0xmahhdb2ycnlq5vve1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fu0xmahhdb2ycnlq5vve1.png" alt="The deployed request path — MCP client sends an Amazon Cognito Bearer token; API Gateway's JWT authorizer validates it at the edge; the Lambda re-checks scope, logs the caller, and calls Tavily with a key it reads from Secrets Manager and never returns to the client." width="800" height="852"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The split is the whole point: &lt;strong&gt;API Gateway answers "is this a real, valid token?" and Lambda answers "is this identity allowed to do this, and let's record that they did."&lt;/strong&gt; Authentication at the edge, authorization in your code, the secret sealed behind both.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why &lt;code&gt;type: http&lt;/code&gt;, not &lt;code&gt;type: stdio&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;There's a reason this wrapper is a &lt;em&gt;remote&lt;/em&gt; MCP server and not a local one — and it's the same reason the talk put OAuth-fronted remote servers at the centre of the architecture. An MCP client config gives you three transport choices, and the choice quietly decides your entire security story:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;stdio&lt;/code&gt;&lt;/strong&gt; — the client spawns a local process (&lt;code&gt;npx some-mcp&lt;/code&gt;, a Python script) and talks to it over stdin/stdout. The catch: that process needs the upstream credential &lt;em&gt;on the developer's machine&lt;/em&gt;. So the key lands in &lt;code&gt;mcp.json&lt;/code&gt;, in an &lt;code&gt;env&lt;/code&gt; block, in shell history, in a dotfile that gets synced to who-knows-where. Every laptop is now a copy of the shared key. This is the exact shape of the BYOAI / shared-key problem — one credential, sprayed everywhere, impossible to attribute or revoke cleanly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;sse&lt;/code&gt;&lt;/strong&gt; — the older remote transport. Remote is the right instinct, but plain SSE was only ever a transport; it never carried an auth story of its own, so in practice people bolted a static bearer token onto it and landed right back at "one shared secret."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;http&lt;/code&gt;&lt;/strong&gt; (Streamable HTTP) — a remote endpoint the client reaches over plain HTTPS, and the transport the 2025 MCP spec standardised on. Crucially, the &lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;spec defines the MCP server as an &lt;strong&gt;OAuth resource server&lt;/strong&gt;&lt;/a&gt; — so authentication is a first-class part of the transport, not an afterthought. The client config holds &lt;em&gt;no secret at all&lt;/em&gt;; it just points at a URL and lets OAuth do the rest. (With an OAuth-native client the browser login is automatic; clients that don't yet drive the flow themselves lean on a small local helper to do it — more on that below.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the whole pitch. Compare the two configs a developer actually writes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// stdio — the secret lives on every laptop&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tavily"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stdio"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"tavily-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"TAVILY_API_KEY"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"tvly-SHARED-KEY-everyone-has-this"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c1"&gt;// http — no secret, OAuth per user&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"tavily"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://…/mcp"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;http&lt;/code&gt; version has nothing to leak. The credential never reaches the client; identity is established per-user through Cognito; and the server — not the laptop — is the only thing that ever touches the upstream key. Whenever an upstream &lt;em&gt;can&lt;/em&gt; be reached as a remote OAuth-fronted &lt;code&gt;http&lt;/code&gt; server, it should be. This wrapper exists precisely to turn a &lt;code&gt;stdio&lt;/code&gt;-shaped shared-key tool into an &lt;code&gt;http&lt;/code&gt;-shaped one.&lt;/p&gt;

&lt;h2&gt;
  
  
  One endpoint, two kinds of caller
&lt;/h2&gt;

&lt;p&gt;A backend agent and a human engineer authenticate through completely different OAuth flows — &lt;code&gt;client_credentials&lt;/code&gt; for the machine, &lt;code&gt;authorization_code&lt;/code&gt; + PKCE for the person. But they end up carrying the &lt;em&gt;same&lt;/em&gt; scope and hitting the &lt;em&gt;same&lt;/em&gt; endpoint. The server treats them identically; the only difference is what lands in the audit log — a client ID for the machine, an email for the human.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgqkmdfrjhx0tdfoji32w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgqkmdfrjhx0tdfoji32w.png" alt="The two token origins converge on one MCP endpoint — a machine client via client_credentials and a human via authorization_code + PKCE both arrive carrying the same tavily-mcp/search scope, and the server treats them identically while logging a different caller identity." width="800" height="856"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The last mile most "MCP + OAuth" posts skip
&lt;/h3&gt;

&lt;p&gt;One piece of the human flow is easy to gloss over, and it's exactly where most "remote MCP + OAuth" walkthroughs quietly wave their hands: a static MCP client config (the &lt;code&gt;claude_desktop_config.json&lt;/code&gt; kind) can't pop a browser for a Cognito login on its own. The fix is a thin local helper — registered as the client's &lt;code&gt;stdio&lt;/code&gt; command — that checks for a cached token, triggers the PKCE browser login when it's missing or expired, stashes the result, and forwards each request with the &lt;code&gt;Authorization: Bearer …&lt;/code&gt; header. This is exactly what tools like &lt;a href="https://github.com/geelen/mcp-remote" rel="noopener noreferrer"&gt;&lt;code&gt;mcp-remote&lt;/code&gt;&lt;/a&gt; exist to do — and the &lt;a href="https://github.com/geelen/mcp-remote/issues/251" rel="noopener noreferrer"&gt;rough edges around that login flow&lt;/a&gt; are a recurring &lt;a href="https://www.reddit.com/r/mcp/comments/1mw09b5/how_are_you_handling_oauth_when_running_mcp/" rel="noopener noreferrer"&gt;source of confusion&lt;/a&gt; when people first wire up a remote MCP server. It's a small shim, but without it the "human logs in with PKCE" line is doing a lot of unspoken work. The repo includes a runnable PKCE script you can wire in as that helper.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The Lambda is the &lt;em&gt;final&lt;/em&gt; enforcement point — but the security of the whole scheme rests on the entire chain: the JWT authorizer validating token integrity at the edge, and the Lambda enforcing scope behind it.&lt;/strong&gt; The upstream still sees one shared key; it has no idea which human is behind a call. All the per-user identity, scoping, and audit live in &lt;em&gt;your&lt;/em&gt; layer. So the scheme reduces to a few disciplines:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Lock the secret to only the Lambda's execution role.&lt;/strong&gt; Anyone who can read it gets full upstream access.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make sure every route to the function goes through the JWT authorizer.&lt;/strong&gt; A second unprotected trigger would bypass the whole thing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Machine callers blur the human behind them.&lt;/strong&gt; When a backend agent authenticates with &lt;code&gt;client_credentials&lt;/code&gt;, the audit log names the &lt;em&gt;machine&lt;/em&gt; client, not the person who prompted it. For a human-triggered agent you've narrowed "who" to one service identity, not one person. Closing that last gap means propagating the user's own token down to the agent (OAuth 2.0 token exchange, RFC 8693) instead of falling back to a shared machine credential — worth knowing before you lean on M2M audit lines as proof of &lt;em&gt;who&lt;/em&gt; did something.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mind the 30-second ceiling.&lt;/strong&gt; API Gateway HTTP APIs cap each integration at 30 seconds. A search call returns in well under a second, so it's a non-issue here — but if you wrap a slower upstream (a heavy query, a multi-step scrape, a long-running agent tool), a call that overruns hits a &lt;code&gt;504&lt;/code&gt; at the gateway. That's the point where you'd reach for the transport's SSE streaming channel, or an async submit-then-poll pattern, instead of a single blocking POST.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Get those right and you've genuinely converted a shared god-token into per-user delegation. Get them wrong and you've just added a proxy in front of the same shared key. This is also why the &lt;em&gt;audit log&lt;/em&gt; matters more than it looks: it's the only place "who ran what" is recoverable at all — Tavily's logs will forever show one key.&lt;/p&gt;

&lt;h2&gt;
  
  
  It works
&lt;/h2&gt;

&lt;p&gt;Deployed to a real AWS account, the end-to-end path is exactly what you'd hope. A caller fetches an Amazon Cognito token, calls the MCP tool, and a real Tavily answer comes back &lt;em&gt;through the wrapper&lt;/em&gt; — the client never touches the upstream key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Answer: The Model Context Protocol (MCP) is a standardized framework
enabling AI models to access external data sources and tools securely…

1. What is the Model Context Protocol (MCP)?
   https://www.databricks.com/blog/what-is-model-context-protocol
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the boundary holds: a request with &lt;strong&gt;no token, or a forged one, gets a &lt;code&gt;401&lt;/code&gt; at the edge&lt;/strong&gt; — rejected by API Gateway before the Lambda ever runs. Meanwhile every successful call writes an audit line naming the caller — &lt;code&gt;client 59psk…&lt;/code&gt; or &lt;code&gt;demo@example.com&lt;/code&gt; — something the upstream's shared-key logs physically cannot produce. (Commands and full output are in the repo.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this generalizes
&lt;/h2&gt;

&lt;p&gt;Swap Tavily for a legacy internal API, a SaaS whose own OAuth logs into &lt;em&gt;its&lt;/em&gt; identity instead of yours, or a machine credential you don't want sprayed across developer laptops — the Lambda is the only thing that changes, and the payoff is the same. A leaked Amazon Cognito access token is short-lived and expires on its own, and you cut a compromised identity off at the source by revoking its refresh token (and disabling the user/app client) so it can't mint new ones — versus a leaked shared key, which means rotate-and-redeploy for the whole team. (Worth knowing: with the edge JWT authorizer, an already-issued access token stays valid until it expires; for instant kill-switch revocation you'd add a server-side check in the Lambda.) It ties straight back to the Golden Rule from the talk — &lt;em&gt;if a human can't do it in the UI, the agent can't do it via MCP&lt;/em&gt; — because the agent inherits exactly the caller's scope, nothing more. That's what makes team-wide AI-assisted coding safe to roll out: developers plug a powerful shared-key tool straight into their own IDE, and security doesn't have to say no, because there's no shared credential on the laptop to leak — just the developer's own scoped, revocable session.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does all this cost? Almost nothing.
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes this an easy yes: the entire control plane is serverless and pay-per-use, so for a team's real workload it rounds to a rounding error. Amazon Cognito is free up to 50,000 monthly active users. Lambda's free tier covers a million requests a month. An idle HTTP API costs nothing and is about $1 per million requests after that.&lt;/p&gt;

&lt;p&gt;The one real decision is where the shared upstream key lives. An encrypted Lambda environment variable is free but readable by anyone with &lt;code&gt;lambda:GetFunctionConfiguration&lt;/code&gt; and baked into the deployment — fine for a demo, not for production. SSM Parameter Store &lt;code&gt;SecureString&lt;/code&gt; is also free and gives the same &lt;em&gt;at-rest&lt;/em&gt; protection for a static key (KMS-encrypted, IAM-scoped reads) — the sweet spot for most single-key wrappers. AWS Secrets Manager costs ~$0.40/month but adds the operational layer you may actually need: automatic rotation, resource policies, cross-account sharing. I used Secrets Manager in the reference because it makes the lockdown story explicit, but Parameter Store swaps in with a few lines. Either way the boundary is identical: IAM grants read to &lt;em&gt;only&lt;/em&gt; the Lambda's execution role, and the key never leaves the server.&lt;/p&gt;

&lt;p&gt;You also get &lt;em&gt;measurement&lt;/em&gt; nearly for free: because every call carries a real identity, your Lambda can log structured per-user, per-tool fields, and CloudWatch Logs Insights (or an EMF metric) turns them into per-identity rate limits, anomaly alerts, or usage you bill back to a team. The shared-key world gives you one undifferentiated blob of traffic and none of that.&lt;/p&gt;

&lt;p&gt;Full source — &lt;code&gt;template.yaml&lt;/code&gt;, the Lambda, both flow scripts, and a one-command teardown — is on GitHub. Clone it, point it at your own shared-key API, and you've got per-user OAuth in an afternoon.&lt;/p&gt;




&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The talk this builds on&lt;/strong&gt; — &lt;a href="https://the-quantum-nargle.github.io/agentcon-2026-hk-slides/" rel="noopener noreferrer"&gt;"Empower Team-Wide Vibe Coding with LLM Gateway and Security-First MCPs"&lt;/a&gt; (Gabriel Koo &amp;amp; Rakshit Jain, AgentCon HK 2026).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interactive demo&lt;/strong&gt; — &lt;a href="https://gabrielkoo.github.io/tavily-oauth-mcp-wrapper/" rel="noopener noreferrer"&gt;click through the full OAuth flow&lt;/a&gt; (login, scoped call, 403 on out-of-scope tool, and the &lt;code&gt;stdio&lt;/code&gt; vs &lt;code&gt;http&lt;/code&gt; config contrast).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runnable demo &amp;amp; full source&lt;/strong&gt;: &lt;a href="https://github.com/gabrielkoo/tavily-oauth-mcp-wrapper" rel="noopener noreferrer"&gt;github.com/gabrielkoo/tavily-oauth-mcp-wrapper&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>Resurface Claude Code Usage Across Your Team with CloudWatch OTEL (No Lambda)</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Sat, 18 Apr 2026 12:59:36 +0000</pubDate>
      <link>https://dev.to/aws-builders/resurface-claude-code-usage-across-your-team-with-cloudwatch-otel-no-lambda-4p0i</link>
      <guid>https://dev.to/aws-builders/resurface-claude-code-usage-across-your-team-with-cloudwatch-otel-no-lambda-4p0i</guid>
      <description>&lt;p&gt;I've been building AI tooling infrastructure to empower a team of 50+ software engineers to do vibe coding safely. We went from 3 engineers using AI full-time to 50+ in 6 months — including non-engineers. (I co-presented on this journey at &lt;a href="https://the-quantum-nargle.github.io/agentcon-2026-hk-slides/" rel="noopener noreferrer"&gt;AgentCon Hong Kong 2026&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;One thing we learned: &lt;strong&gt;you can't improve what you can't measure.&lt;/strong&gt; Once you give a team AI coding tools, you want visibility into how they're being used — not to evaluate individual engineers, but to understand adoption patterns. Which tools are people reaching for? How large are the prompts? What tool calls are being made? Making these metrics visible to everyone helps the team learn from each other and helps champions pull others forward.&lt;/p&gt;

&lt;p&gt;This post is about the plumbing: how to get that telemetry data from coding agents into CloudWatch with minimal infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"But we already have an LLM gateway."&lt;/strong&gt; If your team routes AI traffic through a gateway like &lt;a href="https://github.com/BerriAI/litellm" rel="noopener noreferrer"&gt;LiteLLM&lt;/a&gt; or &lt;a href="https://aws.amazon.com/bedrock/" rel="noopener noreferrer"&gt;AWS Bedrock&lt;/a&gt;, you already have token-level usage data. But if your engineers are on coding plans — Claude Team/Max, OpenCode Go, GitHub Copilot seats, ChatGPT Codex — the LLM calls bypass your gateway entirely. You lose visibility into the interesting stuff: how many tool calls per session, prompt sizes, which tools are being invoked, who's active at what times. That's where OTEL telemetry fills the gap.&lt;/p&gt;

&lt;p&gt;AI coding tools are shipping with built-in OpenTelemetry support. &lt;a href="https://docs.anthropic.com/en/docs/claude-code/monitoring-usage" rel="noopener noreferrer"&gt;Claude Code&lt;/a&gt;, &lt;a href="https://support.claude.com/en/articles/14477985-monitor-claude-cowork-activity-with-opentelemetry" rel="noopener noreferrer"&gt;Claude CoWork&lt;/a&gt;, &lt;a href="https://docs.github.com/copilot/how-tos/copilot-sdk/observability/opentelemetry" rel="noopener noreferrer"&gt;GitHub Copilot&lt;/a&gt;, &lt;a href="https://geminicli.com/docs/cli/telemetry/" rel="noopener noreferrer"&gt;Gemini CLI&lt;/a&gt;, and &lt;a href="https://github.com/LangGuard-AI/cursor-otel-hook" rel="noopener noreferrer"&gt;Cursor&lt;/a&gt; (via hooks) all export metrics, traces, and log events over OTLP/HTTP — token counts, tool durations, model latency, the works. &lt;a href="https://github.com/kirodotdev/Kiro/issues/6319" rel="noopener noreferrer"&gt;Kiro has an open feature request&lt;/a&gt; for native OTEL support too.&lt;/p&gt;

&lt;p&gt;There's one catch: &lt;strong&gt;CloudWatch's OTLP endpoints require SigV4 signing.&lt;/strong&gt; These tools' OTEL SDKs can't do that. Neither can most OTEL SDKs without an AWS-specific exporter or a collector sidecar.&lt;/p&gt;

&lt;p&gt;The usual fix is a Lambda function that receives OTLP, signs it, and forwards it. That means cold starts, packaging, and another thing to maintain.&lt;/p&gt;

&lt;p&gt;Here's a simpler way: &lt;strong&gt;API Gateway REST API with AWS Service Integration.&lt;/strong&gt; APIGW signs the request with SigV4 using an execution role. No Lambda. No collector. No code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fef5flvd70ipoiq5n5psj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fef5flvd70ipoiq5n5psj.png" alt="Expanding Brain Meme"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Timeline:&lt;/strong&gt; CloudWatch has supported OTLP ingestion for &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;traces and logs&lt;/a&gt; for some time (availability varies by region — check the &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;OTLP endpoints doc&lt;/a&gt;). &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-cloudwatch-opentelemetry-metrics/" rel="noopener noreferrer"&gt;Native OTLP metrics support launched April 2, 2026&lt;/a&gt; in public preview, completing all three pillars of observability via OTLP.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk3fguhkeofv8x6gf2e2w.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fk3fguhkeofv8x6gf2e2w.png" alt="Architecture"&gt;&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI Coding Tool (OTEL SDK)
  ↓ OTLP/HTTP + x-api-key
API Gateway REST API
  ├→ POST /v1/metrics  → AWS Integration → monitoring (SigV4) → CloudWatch Metrics
  ├→ POST /v1/traces   → AWS Integration → xray (SigV4)      → X-Ray / CloudWatch Logs
  └→ POST /v1/logs     → AWS Integration → logs (SigV4)       → CloudWatch Logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client sends standard OTLP/HTTP requests with an API key. APIGW validates the key, assumes an IAM role, signs the request with SigV4, and forwards it to the CloudWatch OTLP endpoint. That's it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why This Works
&lt;/h2&gt;

&lt;p&gt;API Gateway REST API has an integration type called &lt;strong&gt;&lt;a href="https://docs.aws.amazon.com/apigateway/latest/developerguide/getting-started-aws-proxy.html" rel="noopener noreferrer"&gt;AWS Service Integration&lt;/a&gt;&lt;/strong&gt;. It can call any AWS service API and sign the request with SigV4 using an execution role. The &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;CloudWatch OTLP endpoints&lt;/a&gt; are standard AWS service endpoints:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Endpoint&lt;/th&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Metrics&lt;/td&gt;
&lt;td&gt;&lt;code&gt;monitoring.{region}.amazonaws.com/v1/metrics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;monitoring&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traces&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xray.{region}.amazonaws.com/v1/traces&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;xray&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;logs.{region}.amazonaws.com/v1/logs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;logs&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;APIGW's integration URI format maps directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;arn:aws:apigateway:{region}:monitoring:path/v1/metrics
arn:aws:apigateway:{region}:xray:path/v1/traces
arn:aws:apigateway:{region}:logs:path/v1/logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;p&gt;The full infrastructure is defined in a CloudFormation template (link at the bottom). Here's what it creates:&lt;/p&gt;

&lt;h3&gt;
  
  
  IAM Execution Role
&lt;/h3&gt;

&lt;p&gt;APIGW needs an IAM role to sign requests to CloudWatch. The policy is scoped to only the actions and resources needed for OTLP ingestion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;Parameters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;OtlpLogGroupName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;String&lt;/span&gt;
    &lt;span class="na"&gt;Default&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;otlp-logs"&lt;/span&gt;
    &lt;span class="na"&gt;Description&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CloudWatch Logs log group for OTLP log ingestion&lt;/span&gt;

&lt;span class="na"&gt;Resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;OtlpExecutionRole&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::IAM::Role&lt;/span&gt;
    &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;AssumeRolePolicyDocument&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;Statement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow&lt;/span&gt;
            &lt;span class="na"&gt;Principal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;Service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apigateway.amazonaws.com&lt;/span&gt;
            &lt;span class="na"&gt;Action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;sts:AssumeRole&lt;/span&gt;
      &lt;span class="na"&gt;Policies&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;PolicyName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otlp-metrics&lt;/span&gt;
          &lt;span class="na"&gt;PolicyDocument&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;Statement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow&lt;/span&gt;
                &lt;span class="na"&gt;Action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cloudwatch:PutMetricData&lt;/span&gt;
                &lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;PolicyName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otlp-traces&lt;/span&gt;
          &lt;span class="na"&gt;PolicyDocument&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;Statement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow&lt;/span&gt;
                &lt;span class="na"&gt;Action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xray:PutTraceSegments&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;xray:PutTelemetryRecords&lt;/span&gt;
                &lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;PolicyName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;otlp-logs&lt;/span&gt;
          &lt;span class="na"&gt;PolicyDocument&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;Statement&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;Effect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Allow&lt;/span&gt;
                &lt;span class="na"&gt;Action&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;logs:PutLogEvents&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;logs:CreateLogStream&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;logs:DescribeLogStreams&lt;/span&gt;
                &lt;span class="na"&gt;Resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="kt"&gt;!Sub&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${OtlpLogGroupName}:*"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note: &lt;code&gt;cloudwatch:PutMetricData&lt;/code&gt; doesn't support resource-level ARNs. The &lt;code&gt;cloudwatch:namespace&lt;/code&gt; condition key exists but does not apply to the OTLP ingestion path — metrics are accepted regardless of namespace. X-Ray &lt;code&gt;PutTraceSegments&lt;/code&gt; also doesn't support resource-level restrictions. Logs permissions are scoped to a specific log group via the &lt;code&gt;OtlpLogGroupName&lt;/code&gt; parameter.&lt;/p&gt;

&lt;h3&gt;
  
  
  API Gateway with AWS Service Integration
&lt;/h3&gt;

&lt;p&gt;Each OTLP signal gets its own resource with an AWS integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;MetricsMethod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::ApiGateway::Method&lt;/span&gt;
  &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;HttpMethod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
    &lt;span class="na"&gt;AuthorizationType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NONE&lt;/span&gt;
    &lt;span class="na"&gt;ApiKeyRequired&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;Integration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS&lt;/span&gt;
      &lt;span class="na"&gt;IntegrationHttpMethod&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;POST&lt;/span&gt;
      &lt;span class="na"&gt;Uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Sub&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;arn:aws:apigateway:${AWS::Region}:monitoring:path/v1/metrics"&lt;/span&gt;
      &lt;span class="na"&gt;Credentials&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!GetAtt&lt;/span&gt; &lt;span class="s"&gt;OtlpExecutionRole.Arn&lt;/span&gt;
      &lt;span class="na"&gt;PassthroughBehavior&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;WHEN_NO_MATCH&lt;/span&gt;
      &lt;span class="na"&gt;ContentHandling&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CONVERT_TO_TEXT&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same pattern for &lt;code&gt;/v1/traces&lt;/code&gt; (service: &lt;code&gt;xray&lt;/code&gt;) and &lt;code&gt;/v1/logs&lt;/code&gt; (service: &lt;code&gt;logs&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  API Key Authentication
&lt;/h3&gt;

&lt;p&gt;Protect the endpoint with an API key so only your tools can send telemetry:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ApiKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::ApiGateway::ApiKey&lt;/span&gt;
  &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;Enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="na"&gt;UsagePlan&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWS::ApiGateway::UsagePlan&lt;/span&gt;
  &lt;span class="na"&gt;Properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;ApiStages&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;ApiId&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;Api&lt;/span&gt;
        &lt;span class="na"&gt;Stage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;!Ref&lt;/span&gt; &lt;span class="s"&gt;Stage&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configure Your Tools
&lt;/h2&gt;

&lt;p&gt;The proxy works with any tool that supports standard OTEL environment variables. Here's how to configure each:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer:&lt;/strong&gt; I personally use Claude Code routed through a custom LLM gateway (not a coding plan), since some coding plans aren't available in the region I live in. The configurations below are based on each tool's official documentation — your mileage may vary.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Claude Code
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.anthropic.com/en/docs/claude-code/monitoring-usage" rel="noopener noreferrer"&gt;Official monitoring docs&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_ENABLE_TELEMETRY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_METRICS_EXPORTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;otlp
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_LOGS_EXPORTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;otlp
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_PROTOCOL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http/json
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://xxx.execute-api.us-west-2.amazonaws.com/prod
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_HEADERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;x-api-key&lt;span class="o"&gt;=&lt;/span&gt;your-api-key
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_SERVICE_NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;claude-code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For short-lived tasks, lower the export interval so data flushes before the process exits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_METRIC_EXPORT_INTERVAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_LOGS_EXPORT_INTERVAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For traces (beta):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLAUDE_CODE_ENHANCED_TELEMETRY_BETA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_TRACES_EXPORTER&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;otlp
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OTEL_TRACES_EXPORT_INTERVAL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Enforcing OTEL across your team:&lt;/strong&gt; Claude Code supports &lt;a href="https://code.claude.com/docs/en/settings#settings-files" rel="noopener noreferrer"&gt;managed settings&lt;/a&gt; via &lt;code&gt;managed-settings.json&lt;/code&gt;, deployable through MDM (Jamf, Intune, etc.). This lets you enforce OTEL configuration org-wide — engineers don't need to set environment variables manually, and they can't opt out.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claude CoWork (Team &amp;amp; Enterprise)
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://support.claude.com/en/articles/14477985-monitor-claude-cowork-activity-with-opentelemetry" rel="noopener noreferrer"&gt;CoWork monitoring docs&lt;/a&gt; — configure via Admin Settings → Cowork → Monitoring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OTLP endpoint: your APIGW URL&lt;/li&gt;
&lt;li&gt;OTLP protocol: &lt;code&gt;http/json&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OTLP headers: &lt;code&gt;x-api-key=your-api-key&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;CoWork streams user prompts, tool/MCP invocations, file access, human approval decisions, and API request details. It shares the same OTel event schema as Claude Code via the Claude Agent SDK — you can distinguish them by &lt;code&gt;terminal.type&lt;/code&gt; (&lt;code&gt;cowork&lt;/code&gt; vs &lt;code&gt;cli&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  GitHub Copilot CLI
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-command-reference#opentelemetry-monitoring" rel="noopener noreferrer"&gt;Copilot CLI OTel reference&lt;/a&gt; — available since Copilot CLI 1.0.4:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_OTEL_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://xxx.execute-api.us-west-2.amazonaws.com/prod
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;COPILOT_OTEL_HEADERS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;x-api-key&lt;span class="o"&gt;=&lt;/span&gt;your-api-key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Gemini CLI
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://geminicli.com/docs/cli/telemetry/" rel="noopener noreferrer"&gt;Gemini CLI telemetry docs&lt;/a&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;GEMINI_CLI_OTEL_EXPORT_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://xxx.execute-api.us-west-2.amazonaws.com/prod
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Cursor (via Hooks)
&lt;/h3&gt;

&lt;p&gt;Cursor doesn't have native OTEL export yet, but the community &lt;a href="https://github.com/LangGuard-AI/cursor-otel-hook" rel="noopener noreferrer"&gt;cursor-otel-hook&lt;/a&gt; project captures agent activity via Cursor's hook system and exports traces to any OTLP endpoint. Configure via &lt;code&gt;otel_config.json&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"OTEL_EXPORTER_OTLP_ENDPOINT"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://xxx.execute-api.us-west-2.amazonaws.com/prod/v1/traces"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"OTEL_EXPORTER_OTLP_PROTOCOL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"OTEL_EXPORTER_OTLP_HEADERS"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"x-api-key"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-api-key"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What You Get
&lt;/h2&gt;

&lt;p&gt;CloudWatch receives standard OTLP data. For Claude Code specifically:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Metrics&lt;/strong&gt;: &lt;code&gt;claude_code.token.usage&lt;/code&gt; (by &lt;code&gt;token.type&lt;/code&gt;: input/output/cache_read/cache_creation), &lt;code&gt;claude_code.cost.usage&lt;/code&gt; (USD), &lt;code&gt;claude_code.session.count&lt;/code&gt;, &lt;code&gt;claude_code.lines_of_code.count&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Traces&lt;/strong&gt; (beta): Spans linking each user prompt → API requests → tool executions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Log events&lt;/strong&gt;: &lt;code&gt;claude_code.user_prompt&lt;/code&gt;, &lt;code&gt;claude_code.tool_decision&lt;/code&gt;, &lt;code&gt;claude_code.tool_result&lt;/code&gt;, &lt;code&gt;claude_code.api_request&lt;/code&gt; — all tagged with &lt;code&gt;session.id&lt;/code&gt; and &lt;code&gt;service.name=claude-code&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's what real Claude Code log events look like after flowing through the proxy into CloudWatch Logs. This is actual data from an E2E test — a single prompt that triggered a Bash tool call:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;claude_code.user_prompt&lt;/code&gt;&lt;/strong&gt; — emitted when the user sends a prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resource"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"host.arch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"arm64"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"os.type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"linux"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"service.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"service.version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.1.114"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"os.version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"6.17.0-1010-aws"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scope"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"com.anthropic.claude_code.events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.1.114"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude_code.user_prompt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event.sequence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"user.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1c257d04..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt_length"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"40"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"terminal.type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"non-interactive"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event.name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"user_prompt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"event.timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-04-18T11:23:13.187Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;REDACTED&amp;gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"session.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"846ab649-8bba-471e-8ec5-8756116d0840"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"88475ee2-59c2-4137-9201-5540c6a6cad1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;claude_code.tool_result&lt;/code&gt;&lt;/strong&gt; — emitted after each tool execution:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude_code.tool_result"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tool_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bash"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tool_result_size_bytes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"899"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"tool_input"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;command&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;ls&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;description&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;List files in current directory&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"95"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"true"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"session.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"846ab649-8bba-471e-8ec5-8756116d0840"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"88475ee2-59c2-4137-9201-5540c6a6cad1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;claude_code.api_request&lt;/code&gt;&lt;/strong&gt; — emitted after each API call with token counts and cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude_code.api_request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"attributes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"claude-sonnet-4-5-20250929"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"input_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"142"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"output_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"61"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cache_read_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cache_creation_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"25848"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cost_usd"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.098271"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"4950"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"speed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"normal"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"session.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"846ab649-8bba-471e-8ec5-8756116d0840"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt.id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"88475ee2-59c2-4137-9201-5540c6a6cad1"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All events share the same &lt;code&gt;prompt.id&lt;/code&gt;, linking them into a single interaction. The &lt;code&gt;event.sequence&lt;/code&gt; field orders events within a prompt. Every record carries &lt;code&gt;service.name=claude-code&lt;/code&gt; in resource attributes, so isolating Claude Code telemetry in a mixed pipeline is trivial — just filter on that in CloudWatch Logs Insights:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;fields&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cost_usd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;duration_ms&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;resource&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attributes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nv"&gt;`service.name`&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'claude-code'&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'claude_code.api_request'&lt;/span&gt;
&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;timestamp&lt;/span&gt; &lt;span class="k"&gt;desc&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Region Availability
&lt;/h2&gt;

&lt;p&gt;CloudWatch OTLP endpoints are available in most regions but &lt;strong&gt;not all&lt;/strong&gt;. The &lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-cloudwatch-opentelemetry-metrics/" rel="noopener noreferrer"&gt;OTLP metrics preview&lt;/a&gt; launched in 5 regions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;th&gt;Regions&lt;/th&gt;
&lt;th&gt;Docs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Metrics (preview)&lt;/td&gt;
&lt;td&gt;us-east-1, us-west-2, ap-southeast-1, ap-southeast-2, eu-west-1&lt;/td&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/about-aws/whats-new/2026/04/amazon-cloudwatch-opentelemetry-metrics/" rel="noopener noreferrer"&gt;Announcement&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Traces&lt;/td&gt;
&lt;td&gt;Most commercial regions&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;OTLP Endpoints&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Logs&lt;/td&gt;
&lt;td&gt;Most commercial regions&lt;/td&gt;
&lt;td&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;OTLP Endpoints&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tested and confirmed:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;Metrics&lt;/th&gt;
&lt;th&gt;Traces&lt;/th&gt;
&lt;th&gt;Logs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;us-east-1&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;us-west-2&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ap-southeast-1&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ap-east-1 (Hong Kong)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If your primary region doesn't support it, deploy the proxy in a supported region. The APIGW endpoint is accessible from anywhere.&lt;/p&gt;

&lt;p&gt;For the full list of CloudWatch service endpoints by region, see the &lt;a href="https://docs.aws.amazon.com/general/latest/gr/cw_region.html" rel="noopener noreferrer"&gt;AWS General Reference&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;XRay traces require manual setup.&lt;/strong&gt; The CloudFormation template creates the proxy endpoints, but X-Ray traces need two additional steps that aren't in the template:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Set CloudWatch Logs as the trace segment destination:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws xray update-trace-segment-destination &lt;span class="nt"&gt;--destination&lt;/span&gt; CloudWatchLogs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Create a CloudWatch Logs resource policy allowing X-Ray to write to the &lt;code&gt;aws/spans&lt;/code&gt; log group:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws logs put-resource-policy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-name&lt;/span&gt; XRayAccessPolicy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--policy-document&lt;/span&gt; &lt;span class="s1"&gt;'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"xray.amazonaws.com"},"Action":["logs:PutLogEvents","logs:CreateLogGroup","logs:CreateLogStream"],"Resource":"*"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without these, traces will return &lt;code&gt;AccessDeniedException&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CloudWatch Logs supports bearer token auth.&lt;/strong&gt; The &lt;code&gt;/v1/logs&lt;/code&gt; endpoint supports &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;bearer token authentication&lt;/a&gt; without SigV4 — but only for logs. Metrics and traces still require SigV4, which is why the APIGW proxy is needed for a unified endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;http/json&lt;/code&gt;, not &lt;code&gt;http/protobuf&lt;/code&gt;.&lt;/strong&gt; CloudWatch accepts both formats, but API Gateway's &lt;code&gt;CONVERT_TO_TEXT&lt;/code&gt; content handling can corrupt binary protobuf payloads in transit. Set &lt;code&gt;OTEL_EXPORTER_OTLP_PROTOCOL=http/json&lt;/code&gt; to avoid this. JSON is also easier to debug in APIGW execution logs. Most coding tools default to protobuf, so you'll need to override this explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API Gateway payload limit.&lt;/strong&gt; REST API has a 10MB payload limit. OTLP batches from coding tools are well under this, but keep it in mind if you're aggregating from multiple sources. CloudWatch's own limits are 1MB for metrics and logs, 5MB for traces (&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;full limits&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;REST API, not HTTP API.&lt;/strong&gt; Only REST API supports the &lt;code&gt;AWS&lt;/code&gt; integration type needed for SigV4 service proxying. HTTP API does not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost
&lt;/h2&gt;

&lt;p&gt;This is about as cheap as it gets for a telemetry pipeline:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API Gateway&lt;/td&gt;
&lt;td&gt;~$3.50 / million requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Metrics&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://aws.amazon.com/cloudwatch/pricing/" rel="noopener noreferrer"&gt;Standard CW pricing&lt;/a&gt; (free during OTel metrics preview)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch Logs&lt;/td&gt;
&lt;td&gt;&lt;a href="https://aws.amazon.com/cloudwatch/pricing/" rel="noopener noreferrer"&gt;Standard CW pricing&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda&lt;/td&gt;
&lt;td&gt;$0 (there is none)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;No idle cost. No provisioned capacity. Pure pay-per-request.&lt;/p&gt;

&lt;p&gt;For comparison: a Lambda-based OTLP forwarder would add ~$0.20/million invocations plus compute time, but gives you retry logic and transformation capabilities. At typical coding agent volumes (a few hundred requests/day per developer), the cost difference is negligible — the real win is operational simplicity.&lt;/p&gt;

&lt;h2&gt;
  
  
  When NOT to Use This
&lt;/h2&gt;

&lt;p&gt;This proxy is optimized for simplicity. It's the right choice for low-to-moderate telemetry volumes from coding agents and developer tools. But it has tradeoffs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Complexity&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Retries&lt;/th&gt;
&lt;th&gt;Multi-destination&lt;/th&gt;
&lt;th&gt;Transformation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;This proxy (APIGW)&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;~$3.50/M req&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OTel Collector&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Compute cost&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda forwarder&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;~$0.20/M + compute&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ADOT SDK (in-app)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;Free (SigV4 native)&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SaaS (Datadog, etc.)&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;td&gt;$$$&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Consider an OTel Collector or Lambda forwarder instead if you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;High throughput&lt;/strong&gt; — thousands of requests/second from many sources&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry and buffering&lt;/strong&gt; — this proxy is fire-and-forget; if CloudWatch returns an error, the data is lost. OTEL SDKs have built-in retry, but only for transient failures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-destination routing&lt;/strong&gt; — fan out to CloudWatch + Datadog + S3 simultaneously&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payload transformation&lt;/strong&gt; — filter, enrich, or redact telemetry before ingestion&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compliance requirements&lt;/strong&gt; — audit trails, guaranteed delivery, or data residency controls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For most coding agent monitoring use cases (a team of 5-50 developers), this proxy handles the volume comfortably.&lt;/p&gt;

&lt;h2&gt;
  
  
  Security Considerations
&lt;/h2&gt;

&lt;p&gt;The proxy uses API key authentication — simple but not the strongest option. Here's how to harden it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attach AWS WAF to the REST API.&lt;/strong&gt; Add rate limiting, IP allowlisting, or geo-blocking to prevent abuse. A single WAF WebACL with a rate-based rule (e.g., 1000 req/5min per IP) costs ~$6/month and stops most abuse patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rotate API keys.&lt;/strong&gt; APIGW supports multiple API keys per usage plan. Create a new key, distribute it, then disable the old one — zero downtime rotation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Consider IAM auth for internal use.&lt;/strong&gt; If your tools run inside AWS (EC2, ECS, Lambda), switch &lt;code&gt;AuthorizationType&lt;/code&gt; from &lt;code&gt;NONE&lt;/code&gt; to &lt;code&gt;AWS_IAM&lt;/code&gt; and drop the API key entirely. The caller signs requests with SigV4 using their IAM role — no shared secrets. This doesn't work for external tools like Claude Code on developer laptops, but it's ideal for CI/CD pipelines or server-side agents.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Egress control.&lt;/strong&gt; If you're running coding agents in a controlled environment, restrict outbound traffic to only your APIGW endpoint. This prevents telemetry from leaking to unauthorized collectors.&lt;/p&gt;

&lt;h2&gt;
  
  
  Beyond Coding Agents
&lt;/h2&gt;

&lt;p&gt;This proxy works with &lt;strong&gt;any OTEL SDK&lt;/strong&gt; that supports OTLP/HTTP. If your tool can set &lt;code&gt;OTEL_EXPORTER_OTLP_ENDPOINT&lt;/code&gt; and &lt;code&gt;OTEL_EXPORTER_OTLP_HEADERS&lt;/code&gt;, it can ship telemetry to CloudWatch through this proxy.&lt;/p&gt;

&lt;p&gt;Potential use cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AI coding agents&lt;/strong&gt; (Claude Code, CoWork, Copilot, Cursor, Gemini CLI) — track token usage, costs, and tool calls across your org&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal tools&lt;/strong&gt; — ship metrics without embedding AWS credentials in client apps&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI/CD pipelines&lt;/strong&gt; — export build/test telemetry to CloudWatch&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-premises services&lt;/strong&gt; — send OTLP from outside AWS without running ADOT Collector&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For apps running inside AWS with IAM roles available, consider the &lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLP-UsingADOT.html" rel="noopener noreferrer"&gt;ADOT SDK&lt;/a&gt; for collector-less telemetry with native SigV4 signing — no proxy needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Source Code &amp;amp; One-Click Deploy
&lt;/h2&gt;

&lt;p&gt;The CloudFormation template and full documentation are on GitHub:&lt;/p&gt;

&lt;p&gt;👉 &lt;a href="https://github.com/gabrielkoo/otlp-cloudwatch-proxy" rel="noopener noreferrer"&gt;gabrielkoo/otlp-cloudwatch-proxy&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One-click deploy to supported regions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Region&lt;/th&gt;
&lt;th&gt;Deploy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;US East (N. Virginia)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://console.aws.amazon.com/cloudformation/home?region=us-east-1#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;amp;stackName=otlp-cloudwatch-proxy" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr2ghcs7c9kfqqlnbap7j.png" alt="Launch Stack"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;US West (Oregon)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;amp;stackName=otlp-cloudwatch-proxy" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr2ghcs7c9kfqqlnbap7j.png" alt="Launch Stack"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asia Pacific (Singapore)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-1#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;amp;stackName=otlp-cloudwatch-proxy" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr2ghcs7c9kfqqlnbap7j.png" alt="Launch Stack"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Asia Pacific (Sydney)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://console.aws.amazon.com/cloudformation/home?region=ap-southeast-2#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;amp;stackName=otlp-cloudwatch-proxy" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr2ghcs7c9kfqqlnbap7j.png" alt="Launch Stack"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Europe (Ireland)&lt;/td&gt;
&lt;td&gt;&lt;a href="https://console.aws.amazon.com/cloudformation/home?region=eu-west-1#/stacks/create/review?templateURL=https://raw.githubusercontent.com/gabrielkoo/otlp-cloudwatch-proxy/main/template.yaml&amp;amp;stackName=otlp-cloudwatch-proxy" rel="noopener noreferrer"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fr2ghcs7c9kfqqlnbap7j.png" alt="Launch Stack"&gt;&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;p&gt;&lt;em&gt;Built and validated on a Saturday morning with Claude Code + OpenClaw. Zero Lambda functions were harmed in the making of this article.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Further Reading
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/aws-solutions-library-samples/guidance-for-claude-code-with-amazon-bedrock/blob/main/assets/docs/MONITORING.md" rel="noopener noreferrer"&gt;AWS Guidance for Claude Code with Amazon Bedrock — Monitoring&lt;/a&gt;&lt;/strong&gt; — A comprehensive (and admittedly overkill) reference implementation using ECS Fargate + ALB + ADOT Collector + Lambda + DynamoDB + Kinesis + Athena. Great if you want to see the full spectrum of what can be measured: per-user token tracking, quota monitoring, cost dashboards, and an analytics data lake. If you need all of that, use it. If you just need telemetry flowing to CloudWatch, the one-template proxy in this post will do.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://code.claude.com/docs/en/monitoring-usage" rel="noopener noreferrer"&gt;Claude Code Monitoring Docs&lt;/a&gt;&lt;/strong&gt; — Official OTEL configuration reference, including all metrics, events, and traces.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://code.claude.com/docs/en/settings#settings-files" rel="noopener noreferrer"&gt;Claude Code Managed Settings&lt;/a&gt;&lt;/strong&gt; — How to deploy &lt;code&gt;managed-settings.json&lt;/code&gt; via MDM for org-wide OTEL enforcement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-OTLPEndpoint.html" rel="noopener noreferrer"&gt;CloudWatch OTLP Endpoints&lt;/a&gt;&lt;/strong&gt; — AWS docs on native OTLP ingestion for metrics, traces, and logs.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>opentelemetry</category>
      <category>cloudwatch</category>
      <category>serverless</category>
    </item>
    <item>
      <title>Bedrock for AI Coding Tools: Mantle vs Gateway vs LiteLLM — A Decision Guide for AWS Credit Burners</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Sun, 22 Mar 2026 08:59:54 +0000</pubDate>
      <link>https://dev.to/aws-builders/bedrock-for-ai-coding-tools-mantle-vs-gateway-vs-litellm-a-decision-guide-for-aws-credit-burners-1h01</link>
      <guid>https://dev.to/aws-builders/bedrock-for-ai-coding-tools-mantle-vs-gateway-vs-litellm-a-decision-guide-for-aws-credit-burners-1h01</guid>
      <description>&lt;p&gt;You have AWS credits. You want to use them on AI coding tools — OpenCode, Codex CLI, Claude Code, whatever. Amazon Bedrock has the models. But how do you actually connect them?&lt;/p&gt;

&lt;p&gt;There are three approaches, and picking the wrong one wastes time. Here's the decision guide I wish I had.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All data in this post is as of March 2026. Model counts and API support may change — check &lt;a href="https://amazonbedrockmodels.github.io" rel="noopener noreferrer"&gt;amazonbedrockmodels.github.io&lt;/a&gt; for the latest.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Just want it to work?&lt;/strong&gt; Mantle + OpenCode. Five minutes, zero infra.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need Claude models via OpenAI API?&lt;/strong&gt; bedrock-access-gateway on Lambda.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Need Claude Code specifically?&lt;/strong&gt; LiteLLM. It's the only path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Codex CLI?&lt;/strong&gt; Broken with all three. Wait for LiteLLM to fix a tool translation bug.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The three paths
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0sln5ko8ufwm73r5r8tb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0sln5ko8ufwm73r5r8tb.png" alt="Decision flowchart: Mantle vs bedrock-access-gateway vs LiteLLM" width="678" height="1295"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One thing all three have in common: your API keys and code context stay within your AWS account or your own infrastructure.&lt;/strong&gt; Third-party AI gateways exist (Bifrost, Portkey, etc.), but they require routing your Bedrock API keys and code context through someone else's servers. Self-hosted or AWS-native — that's the baseline.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Bedrock Mantle — no self-hosted infra required
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/bedrock-mantle.html" rel="noopener noreferrer"&gt;Mantle&lt;/a&gt; is AWS's native OpenAI-compatible endpoint. No Lambda, no container, no proxy — just set your base URL and API key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://bedrock-mantle.us-east-1.api.aws/v1"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-bedrock-api-key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What's on Mantle:&lt;/strong&gt; 38 open-weight models — DeepSeek, Mistral, Qwen, GLM, NVIDIA Nemotron, MiniMax, Moonshot Kimi, Google Gemma, OpenAI gpt-oss, and Writer Palmyra.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's NOT on Mantle:&lt;/strong&gt; Anthropic Claude, Amazon Nova, Meta Llama, AI21, Cohere — the proprietary/first-party models are absent.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API coverage:&lt;/strong&gt; Mantle exposes Chat Completions (&lt;code&gt;/v1/chat/completions&lt;/code&gt;) and Responses API (&lt;code&gt;/v1/responses&lt;/code&gt;). No Anthropic Messages API (&lt;code&gt;/v1/messages&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The Responses API is limited — only 4 models support it: &lt;code&gt;openai.gpt-oss-120b-1:0&lt;/code&gt;, &lt;code&gt;openai.gpt-oss-20b-1:0&lt;/code&gt;, &lt;code&gt;openai.gpt-oss-120b&lt;/code&gt;, and &lt;code&gt;openai.gpt-oss-20b&lt;/code&gt;. Every other model is Chat Completions only. I verified this by scraping all 102 model card pages in the &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html" rel="noopener noreferrer"&gt;AWS documentation&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; Standard Bedrock on-demand pricing. No gateway markup, no infra costs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; OpenCode or any tool that speaks OpenAI Chat Completions.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. bedrock-access-gateway — self-hosted, all models
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/aws-samples/bedrock-access-gateway" rel="noopener noreferrer"&gt;bedrock-access-gateway&lt;/a&gt; (or my fork, &lt;a href="https://github.com/gabrielkoo/bedrock-access-gateway-function-url" rel="noopener noreferrer"&gt;bedrock-access-gateway-function-url&lt;/a&gt;) gives you an OpenAI-compatible proxy backed by all Bedrock models — including Claude, Nova, and Llama.&lt;/p&gt;

&lt;p&gt;Deploy it as a Lambda Function URL or on ECS, and you get:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://your-lambda-url.lambda-url.us-west-2.on.aws/api/v1"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"your-gateway-api-key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tradeoff: you maintain infrastructure. But you get access to every Bedrock model through a single OpenAI-compatible endpoint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; Bedrock on-demand pricing + Lambda/ECS compute costs (minimal for Lambda Function URLs — you pay per invocation).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; When you need Claude or Nova through OpenAI-compatible tools, or want full control over routing, caching, and logging.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. LiteLLM — the universal translator
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/BerriAI/litellm" rel="noopener noreferrer"&gt;LiteLLM&lt;/a&gt; is the Swiss Army knife. It translates between API schemas — OpenAI, Anthropic, Bedrock native, and more. It's the only option that gives you Anthropic Messages API (&lt;code&gt;/v1/messages&lt;/code&gt;) compatibility with Bedrock models.&lt;/p&gt;

&lt;p&gt;This matters because &lt;strong&gt;Claude Code uses the Anthropic API schema&lt;/strong&gt;, not OpenAI's. If you want to run Claude Code against Bedrock, LiteLLM is your best (and arguably only) option. I tested this end-to-end: Claude Code CLI → LiteLLM → Bedrock Converse API — it works, including streaming responses.&lt;/p&gt;

&lt;p&gt;Is it perfect? No. Setup is more complex (Python process or Docker container, optional PostgreSQL for analytics), and you're adding another layer of abstraction. But it's the most flexible gateway available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost:&lt;/strong&gt; Bedrock on-demand pricing + your compute costs for hosting LiteLLM. No per-call markup from LiteLLM itself (open source).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Best for:&lt;/strong&gt; Claude Code, or when you need both OpenAI and Anthropic API compatibility from a single proxy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool compatibility matrix
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;API Schema&lt;/th&gt;
&lt;th&gt;Mantle&lt;/th&gt;
&lt;th&gt;bedrock-access-gateway&lt;/th&gt;
&lt;th&gt;LiteLLM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;td&gt;OpenAI Chat&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex CLI&lt;/td&gt;
&lt;td&gt;OpenAI Responses&lt;/td&gt;
&lt;td&gt;❌ Auth issues&lt;/td&gt;
&lt;td&gt;❌ No Responses API&lt;/td&gt;
&lt;td&gt;⚠️ Tool bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;Anthropic Messages&lt;/td&gt;
&lt;td&gt;❌ No support&lt;/td&gt;
&lt;td&gt;❌ Wrong schema&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Note: Anthropic-native tools like Kiro CLI also work through LiteLLM's Anthropic Messages API translation.&lt;/p&gt;

&lt;h3&gt;
  
  
  A note on Codex CLI
&lt;/h3&gt;

&lt;p&gt;Codex CLI requires the Responses API (&lt;code&gt;/v1/responses&lt;/code&gt;), which limits your options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mantle:&lt;/strong&gt; Only the 4 OpenAI gpt-oss models support Responses API. Even with those, I hit 401 auth errors (Bearer token not passed correctly through Codex's HTTPS transport) and tool type rejections (&lt;code&gt;web_search&lt;/code&gt; type not supported — only &lt;code&gt;function&lt;/code&gt; and &lt;code&gt;mcp&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bedrock-access-gateway:&lt;/strong&gt; No Responses API at all — &lt;code&gt;/v1/responses&lt;/code&gt; returns 404. The gateway only implements Chat Completions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LiteLLM:&lt;/strong&gt; Supports Responses API (&lt;a href="https://github.com/BerriAI/litellm/releases" rel="noopener noreferrer"&gt;v1.66.3+&lt;/a&gt;) and has an &lt;a href="https://docs.litellm.ai/docs/tutorials/openai_codex" rel="noopener noreferrer"&gt;official Codex CLI tutorial&lt;/a&gt;. However, as of v1.82.5, there's a tool translation bug: Codex CLI sends built-in tool types that LiteLLM converts to Bedrock Converse format with empty &lt;code&gt;toolSpec.name&lt;/code&gt; fields, causing Bedrock validation errors. The Responses API itself works fine when tested with standard function tools. This should be fixable on the LiteLLM side.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick setup: OpenCode + Mantle
&lt;/h2&gt;

&lt;p&gt;If you just want to burn AWS credits on a coding CLI today, here's the fastest path. &lt;a href="https://opencode.ai" rel="noopener noreferrer"&gt;OpenCode&lt;/a&gt; (v1.2.27+) works with Mantle out of the box:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"$schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://opencode.ai/config.json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"bedrock-mantle"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"npm"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"@ai-sdk/openai-compatible"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bedrock Mantle"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"options"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"baseURL"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://bedrock-mantle.us-east-1.api.aws/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{env:BEDROCK_API_KEY}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"openai.gpt-oss-120b"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GPT OSS 120B"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"zai.glm-5"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GLM 5 (744B/40B MoE)"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"qwen.qwen3-coder-480b-a35b-instruct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Qwen3 Coder 480B"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"deepseek.v3.2"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DeepSeek V3.2"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"mistral.mistral-large-3-675b-instruct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Mistral Large 3"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock-mantle/openai.gpt-oss-120b"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save to &lt;code&gt;~/.config/opencode/opencode.json&lt;/code&gt;, set &lt;code&gt;BEDROCK_API_KEY&lt;/code&gt;, and you're coding.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Mantle&lt;/th&gt;
&lt;th&gt;bedrock-access-gateway&lt;/th&gt;
&lt;th&gt;LiteLLM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infra to maintain&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;Lambda/ECS&lt;/td&gt;
&lt;td&gt;Container/process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Models available&lt;/td&gt;
&lt;td&gt;38 (open-weight)&lt;/td&gt;
&lt;td&gt;All Bedrock&lt;/td&gt;
&lt;td&gt;All Bedrock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Chat API&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Responses API&lt;/td&gt;
&lt;td&gt;⚠️ gpt-oss only&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anthropic Messages API&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenCode&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex CLI&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;⚠️ Tool bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra cost&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;~$0 (Lambda)&lt;/td&gt;
&lt;td&gt;Your compute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;td&gt;1 hr&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Track available Mantle models
&lt;/h2&gt;

&lt;p&gt;I maintain &lt;a href="https://amazonbedrockmodels.github.io" rel="noopener noreferrer"&gt;amazonbedrockmodels.github.io&lt;/a&gt; — a catalog of every Bedrock model with API support badges and endpoint support (Mantle vs Runtime), scraped from the AWS documentation.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Burning AWS credits on something interesting? I'd love to hear what tools and models you're using — drop a comment.&lt;/em&gt;&lt;br&gt;
 comment.*&lt;/p&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>ai</category>
      <category>openai</category>
    </item>
    <item>
      <title>From 3-Minute Cold Starts to ~20 Seconds: Whisper on AWS Lambda + EFS for OpenClaw</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Fri, 13 Mar 2026 01:27:21 +0000</pubDate>
      <link>https://dev.to/aws-builders/from-3-minute-cold-starts-to-20-seconds-whisper-on-aws-lambda-efs-for-openclaw-9c5</link>
      <guid>https://dev.to/aws-builders/from-3-minute-cold-starts-to-20-seconds-whisper-on-aws-lambda-efs-for-openclaw-9c5</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 3 of my series on building a low-cost personal AI stack on AWS.&lt;/em&gt; &lt;br&gt;
&lt;em&gt;&lt;a href="https://dev.to/aws-builders/i-squeezed-my-1k-monthly-openclaw-api-bill-with-20month-in-aws-credits-heres-the-exact-setup-3gj4"&gt;Part 1 — Squeezing my $1k/month API bill to $20/month with AWS Credits&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://dev.to/aws-builders/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9"&gt;Part 2 — Drop-in Perplexity Sonar replacement with AWS Bedrock Nova Grounding&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;p&gt;I built a self-hosted speech-to-text API on AWS Lambda using &lt;a href="https://github.com/SYSTRAN/faster-whisper" rel="noopener noreferrer"&gt;faster-whisper&lt;/a&gt;. After trying Amazon Transcribe, SageMaker Serverless, and Lambda with a bundled model, I landed on a &lt;strong&gt;Lambda + EFS + S3&lt;/strong&gt; architecture that achieves ~20-30 second cold starts (once the model is cached on EFS) for ~$0.21/month in storage costs. Once warm, specifying the language drops response time to ~10s.&lt;/p&gt;

&lt;p&gt;Open source: &lt;a href="https://github.com/gabrielkoo/aws-lambda-whisper-adaptor" rel="noopener noreferrer"&gt;gabrielkoo/aws-lambda-whisper-adaptor&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;I wanted to automatically transcribe Telegram voice messages. The requirements were simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accuracy&lt;/strong&gt;: Good enough for Cantonese&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: Pay-per-use, scales to zero when idle&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: Cold start under 60 seconds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's a fourth constraint that's easy to overlook outside Hong Kong: &lt;strong&gt;most managed STT APIs simply aren't available here&lt;/strong&gt;. OpenAI's Whisper API falls under their notorious China's regional restriction. Google's Gemini models are available and actually competitive on both accuracy and price — Gemini 3 Flash achieves 3.1% WER at ~$1.92/1000 minutes (&lt;a href="https://artificialanalysis.ai/speech-to-text" rel="noopener noreferrer"&gt;Artificial Analysis STT leaderboard&lt;/a&gt;), cheaper than OpenAI's Whisper API and competitive with Lambda at low volume. The real reason I went with Lambda: AWS Credits from the Community Builder program (same theme as the rest of this series) make it effectively free.&lt;/p&gt;

&lt;p&gt;Simple enough. Except it took four attempts to get there.&lt;/p&gt;


&lt;h2&gt;
  
  
  What I Tried (and Why It Didn't Work)
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Option 1: Amazon Transcribe
&lt;/h3&gt;

&lt;p&gt;The obvious first choice — fully managed, pay-per-use, native AWS integration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why I rejected it before even trying:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Amazon Transcribe &lt;a href="https://docs.aws.amazon.com/transcribe/latest/dg/supported-languages.html" rel="noopener noreferrer"&gt;supports &lt;code&gt;zh-CN&lt;/code&gt; and &lt;code&gt;zh-TW&lt;/code&gt;, but not &lt;code&gt;yue&lt;/code&gt; (Cantonese)&lt;/a&gt;. Whisper large-v3-turbo handles Cantonese significantly better, and accuracy matters more than convenience here.&lt;/p&gt;


&lt;h3&gt;
  
  
  Option 2: SageMaker Serverless Inference
&lt;/h3&gt;

&lt;p&gt;SageMaker Serverless scales to zero and handles model serving — sounds perfect.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I deployed a SageMaker Serverless endpoint with faster-whisper. The first invocation after idle:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Container provisioning: ~30s&lt;/li&gt;
&lt;li&gt;Model loading: ~45-60s&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Total cold start: 60-90 seconds&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a voice message that's 5-10 seconds long, waiting 90 seconds is a terrible experience.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The 6GB memory wall:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;SageMaker Serverless &lt;a href="https://docs.aws.amazon.com/sagemaker/latest/dg/serverless-endpoints.html" rel="noopener noreferrer"&gt;maxes out at 6144 MB (6 GB) RAM&lt;/a&gt;. Here's why that's a problem for Whisper:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/Zoont/faster-whisper-large-v3-turbo-int8-ct2" rel="noopener noreferrer"&gt;&lt;code&gt;whisper-large-v3-turbo&lt;/code&gt; (INT8)&lt;/a&gt;: ~780MB model + ~2GB Python/runtime overhead ≈ 2.8GB minimum&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://huggingface.co/Systran/faster-whisper-large-v3" rel="noopener noreferrer"&gt;&lt;code&gt;whisper-large-v3&lt;/code&gt; (FP16)&lt;/a&gt;: ~3GB model alone — barely fits, zero headroom for audio processing&lt;/li&gt;
&lt;li&gt;Any concurrent requests? You're OOM.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html" rel="noopener noreferrer"&gt;Lambda goes up to 10,240 MB&lt;/a&gt;. That headroom matters.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cost comparison:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/sagemaker/pricing/" rel="noopener noreferrer"&gt;SageMaker Serverless bills per GB-second&lt;/a&gt; of inference time. For sporadic voice message transcription (~10s per request, a few times a day), Lambda's per-invocation pricing is significantly cheaper. My Lambda setup costs ~$0.21/month in storage — the compute is essentially free at this volume.&lt;/p&gt;

&lt;p&gt;I deleted the endpoint after testing.&lt;/p&gt;


&lt;h3&gt;
  
  
  Option 2b: Bedrock Marketplace
&lt;/h3&gt;

&lt;p&gt;AWS Bedrock Marketplace &lt;a href="https://aws.amazon.com/blogs/machine-learning/build-a-serverless-audio-summarization-solution-with-amazon-bedrock-and-whisper/" rel="noopener noreferrer"&gt;does list Whisper Large V3 Turbo&lt;/a&gt; — but it deploys on a &lt;strong&gt;dedicated endpoint instance&lt;/strong&gt;. Auto-scaling is available (including scale-to-zero), but that creates a different problem:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keep minimum 1 instance&lt;/strong&gt;: always paying for idle time, even at 3am&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scale to zero&lt;/strong&gt;: cold starts when traffic resumes — SageMaker cold starts are measured in &lt;strong&gt;minutes&lt;/strong&gt;, not seconds&lt;/li&gt;
&lt;li&gt;Not token/usage-based pricing either way&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a Telegram bot that gets a few voice messages a day, you're either burning money on idle instances or waiting minutes for the first message to transcribe. Lambda's ~20-30s cold start looks great by comparison.&lt;/p&gt;


&lt;h3&gt;
  
  
  Option 3: Lambda with Bundled Model
&lt;/h3&gt;

&lt;p&gt;Next idea: bundle the model directly into the Docker image. No external dependencies, simple architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happened:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Download model during build&lt;/span&gt;
&lt;span class="c"&gt;# Note: using openai/whisper-large-v3-turbo converted to int8 via sync-model workflow&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from faster_whisper import WhisperModel; WhisperModel('openai/whisper-large-v3-turbo')"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Docker image size: &lt;strong&gt;~10GB&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;ECR push time: &lt;strong&gt;5+ minutes&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Lambda cold start: &lt;strong&gt;2 minutes 51 seconds&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cold start is dominated by Lambda pulling the 10GB image from ECR. AWS Lambda caches images, but any cold start after the cache expires hits this wall.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why it didn't work:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;3-minute cold start is unusable for interactive transcription&lt;/li&gt;
&lt;li&gt;Every code change requires rebuilding and pushing a 10GB image&lt;/li&gt;
&lt;li&gt;ECR storage: ~$1/month just for the image&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Option 4: Lambda + S3 (No EFS)
&lt;/h3&gt;

&lt;p&gt;What if Lambda downloads the model from S3 on cold start, storing it in &lt;code&gt;/tmp&lt;/code&gt;?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The problem:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Lambda's &lt;code&gt;/tmp&lt;/code&gt; is ephemeral. Every cold start re-downloads the model from S3:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;S3 download for 1.6GB FP16 model: &lt;strong&gt;30-60 seconds&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;S3 download for 780MB INT8 model: &lt;strong&gt;15-30 seconds&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is better than the bundled model approach, but there's a bigger issue: &lt;strong&gt;no caching between Lambda instances&lt;/strong&gt;. If you have 3 concurrent invocations, all 3 download the model independently. You're paying for S3 transfer on every cold start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about Lambda SnapStart or Durable Functions?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AWS added two relevant features since this was written:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;SnapStart for Python&lt;/strong&gt; (Nov 2024): snapshots the initialized execution environment — sounds perfect for caching a loaded model. The catch: &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html" rel="noopener noreferrer"&gt;SnapStart doesn't support container images&lt;/a&gt;. This adaptor is container-based, so it's off the table.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;&lt;a href="https://aws.amazon.com/about-aws/whats-new/2025/12/lambda-durable-multi-step-applications-ai-workflows/" rel="noopener noreferrer"&gt;Lambda Durable Functions&lt;/a&gt;&lt;/strong&gt; (re:Invent 2025): enables multi-step workflows with automatic checkpointing, pause/resume for up to one year, and failure recovery. This is workflow orchestration (think Azure Durable Functions) — useful for multi-step AI pipelines, but not for persisting a 780MB model binary between cold starts.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;EFS remains the right solution for model caching.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Actually Worked: Lambda + EFS + S3
&lt;/h2&gt;

&lt;p&gt;The solution: use &lt;strong&gt;EFS as a persistent model cache&lt;/strong&gt;, bootstrapped from S3. I've used EFS for &lt;a href="https://dev.to/aws-builders/scale-a-stateful-streamlit-chatbot-with-aws-ecs-and-efs-48gm"&gt;persistent Streamlit state on ECS&lt;/a&gt; before — same pattern, different compute layer.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Request → Lambda Function URL
               ↓
          Lambda (VPC)
               ↓ first cold start only: S3 → EFS
              EFS (model cached here permanently)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwezctzw3zlsjpf19q5f1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwezctzw3zlsjpf19q5f1.png" alt="Logic Flow" width="800" height="1846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How it works:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;First cold start&lt;/strong&gt; (once per model): Lambda checks for a marker file on EFS. If missing, downloads model from S3 to EFS (~55s for INT8). Writes marker file. Then loads model into RAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Subsequent cold starts&lt;/strong&gt; (new container, model already on EFS): Marker file exists → load model from EFS into RAM (~20-30s for INT8).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm invocations&lt;/strong&gt; (same container reused): Model already in memory → transcription-only time (~10-22s depending on audio length and whether language is specified).
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;HF_MODEL_REPO&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;HF_MODEL_REPO&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;openai/whisper-large-v3-turbo&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;MODEL_SLUG&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HF_MODEL_REPO&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;--&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;EFS_MODEL_DIR&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/mnt/whisper-models/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MODEL_SLUG&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;span class="n"&gt;MODEL_MARKER&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/mnt/whisper-models/.ready-&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MODEL_SLUG&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;bootstrap_model&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exists&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MODEL_MARKER&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;WhisperModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EFS_MODEL_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cpu&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compute_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;int8&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# First run: sync model from S3 to EFS
&lt;/span&gt;    &lt;span class="n"&gt;s3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;boto3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;client&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s3&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;models/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MODEL_SLUG&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makedirs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EFS_MODEL_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;paginator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_paginator&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;list_objects_v2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paginator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;paginate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Bucket&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MODEL_S3_BUCKET&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;Prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Contents&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Key&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;local_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EFS_MODEL_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;):])&lt;/span&gt;
            &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;makedirs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dirname&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;local_path&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;exist_ok&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;s3&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;MODEL_S3_BUCKET&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;local_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MODEL_MARKER&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# Mark as ready
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;WhisperModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EFS_MODEL_DIR&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;cpu&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;compute_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;int8&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;MODEL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;bootstrap_model&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# Runs at Lambda init time, cached for warm invocations
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why EFS works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;EFS persists across Lambda instances — model is downloaded &lt;strong&gt;once&lt;/strong&gt;, reused forever&lt;/li&gt;
&lt;li&gt;EFS is mounted at &lt;code&gt;/mnt/whisper-models&lt;/code&gt; — Lambda reads it like a local filesystem&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;S3 VPC Gateway Endpoint is free&lt;/strong&gt; — no NAT Gateway needed (saves ~$32/month)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero internet egress&lt;/strong&gt; — Lambda → S3 via VPC Gateway Endpoint, Lambda → EFS within VPC. The Lambda function never reaches the internet. This is a meaningful security benefit when using third-party models from HuggingFace — model weights never leave the AWS network once synced to S3.&lt;/li&gt;
&lt;li&gt;EFS storage: ~$0.19/month for the 780MB INT8 model&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;🔒 &lt;strong&gt;Security note:&lt;/strong&gt; The Lambda runs in a VPC with &lt;strong&gt;no internet access&lt;/strong&gt; — no NAT Gateway, no public subnet. It can only reach EFS (VPC-internal) and S3 (via the free VPC Gateway Endpoint). This means even if you're using a third-party HuggingFace model, the model weights and your audio data never leave the AWS network. No data exfiltration risk, no outbound calls to unknown endpoints.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  INT8 vs FP16: The Model Size Trade-off
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;openai/whisper-large-v3-turbo&lt;/code&gt; model on HuggingFace needs conversion to CTranslate2 format. The &lt;code&gt;sync-model&lt;/code&gt; workflow handles this, converting to INT8 and fixing the &lt;code&gt;num_mel_bins&lt;/code&gt; config. Alternatively, use &lt;a href="https://huggingface.co/Zoont/faster-whisper-large-v3-turbo-int8-ct2" rel="noopener noreferrer"&gt;&lt;code&gt;Zoont/faster-whisper-large-v3-turbo-int8-ct2&lt;/code&gt;&lt;/a&gt; — a pre-converted CTranslate2 INT8 model that works out of the box with &lt;code&gt;quantization=none&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Size (EFS)&lt;/th&gt;
&lt;th&gt;First Bootstrap&lt;/th&gt;
&lt;th&gt;EFS Cold Start&lt;/th&gt;
&lt;th&gt;Warm (2.5s audio)&lt;/th&gt;
&lt;th&gt;Memory&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Zoont/faster-whisper-large-v3-turbo-int8-ct2&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;~780MB&lt;/td&gt;
&lt;td&gt;~55s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~22s&lt;/strong&gt; ✅&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;~10s&lt;/strong&gt; ✅&lt;/td&gt;
&lt;td&gt;~2.8GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openai/whisper-large-v3-turbo&lt;/code&gt; (INT8, via sync-model)&lt;/td&gt;
&lt;td&gt;~780MB&lt;/td&gt;
&lt;td&gt;~55s&lt;/td&gt;
&lt;td&gt;~22s&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;td&gt;~2.8GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;openai/whisper-large-v3-turbo&lt;/code&gt; (FP16)&lt;/td&gt;
&lt;td&gt;~1.5GB&lt;/td&gt;
&lt;td&gt;~126s&lt;/td&gt;
&lt;td&gt;~40s&lt;/td&gt;
&lt;td&gt;~15s&lt;/td&gt;
&lt;td&gt;~4GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;Systran/faster-whisper-large-v3&lt;/code&gt; (FP16, loaded as int8)&lt;/td&gt;
&lt;td&gt;~1.6GB&lt;/td&gt;
&lt;td&gt;~54s&lt;/td&gt;
&lt;td&gt;~30s&lt;/td&gt;
&lt;td&gt;~13s&lt;/td&gt;
&lt;td&gt;6GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Recommended:&lt;/strong&gt; &lt;code&gt;Zoont/faster-whisper-large-v3-turbo-int8-ct2&lt;/code&gt; — no conversion step needed, identical performance to the openai model converted to INT8. Use &lt;code&gt;quantization=none&lt;/code&gt; in the sync-model workflow since it's already in CTranslate2 format.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cost Breakdown
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;th&gt;Monthly Cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;EFS storage (780MB INT8)&lt;/td&gt;
&lt;td&gt;~$0.19&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 storage (780MB)&lt;/td&gt;
&lt;td&gt;~$0.02&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda compute&lt;/td&gt;
&lt;td&gt;~$0.00167/warm invocation*&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;S3 VPC Gateway Endpoint&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Free&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;NAT Gateway&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Not needed ($0)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (storage only)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~$0.21/month&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;em&gt;10GB × 10s = 100 GB-seconds per warm invocation. The &lt;a href="https://aws.amazon.com/lambda/pricing/" rel="noopener noreferrer"&gt;Lambda free tier&lt;/a&gt; covers **400,000 GB-seconds/month&lt;/em&gt;* — roughly 4,000 warm invocations. For a personal bot, compute cost is effectively &lt;strong&gt;$0&lt;/strong&gt;. Storage dominates.&lt;/p&gt;

&lt;p&gt;Compare to SageMaker Serverless: minimum ~$5-10/month for similar workloads, plus the 60-90s cold start penalty.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why not Provisioned Concurrency?&lt;/strong&gt; PC keeps Lambda permanently warm (no cold starts), but costs ~$0.0000097222/GB-second. For a 10GB function running 24/7: ~$252/month. Even a minimal 4GB setup runs ~$100/month — roughly 500x more than the $0.21 storage approach. For a personal bot with a few voice messages a day, the occasional ~60s cold start is a fine trade-off.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  vs. OpenAI Whisper API
&lt;/h3&gt;

&lt;p&gt;OpenAI's Whisper API costs &lt;a href="https://openai.com/api/pricing/" rel="noopener noreferrer"&gt;&lt;strong&gt;$0.006/minute&lt;/strong&gt;&lt;/a&gt;. Here's how it compares for a bot averaging 15s voice messages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Volume&lt;/th&gt;
&lt;th&gt;OpenAI Whisper API&lt;/th&gt;
&lt;th&gt;Self-hosted Lambda&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50 msgs/month&lt;/td&gt;
&lt;td&gt;$0.08&lt;/td&gt;
&lt;td&gt;$0.21 (storage only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;140 msgs/month&lt;/td&gt;
&lt;td&gt;$0.21&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;$0.21&lt;/strong&gt; ← break-even&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;500 msgs/month&lt;/td&gt;
&lt;td&gt;$0.75&lt;/td&gt;
&lt;td&gt;$0.21 (storage only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,000 msgs/month&lt;/td&gt;
&lt;td&gt;$1.50&lt;/td&gt;
&lt;td&gt;$0.21 (storage only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4,000 msgs/month&lt;/td&gt;
&lt;td&gt;$6.00&lt;/td&gt;
&lt;td&gt;$0.21 (storage only)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Lambda compute is free within the free tier (~4,000 warm invocations/month). Beyond that, it's $0.00167/invocation — but that's a high volume for a personal bot.&lt;/p&gt;

&lt;p&gt;Break-even: &lt;strong&gt;~140 messages/month&lt;/strong&gt;. Above that, Lambda wins on cost.&lt;/p&gt;

&lt;p&gt;But cost isn't the only reason to self-host:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Geographic availability&lt;/strong&gt;: OpenAI's API is not available in Hong Kong — HK falls under China's regional restriction. Azure OpenAI does offer Whisper, but &lt;a href="https://learn.microsoft.com/en-us/answers/questions/2237575/new-announced-speech-to-text-models-for-realtime" rel="noopener noreferrer"&gt;only &lt;code&gt;whisper-1&lt;/code&gt; (large-v2 based)&lt;/a&gt; — large-v3 and large-v3-turbo are not available. If you're in HK (or other restricted regions), this approach isn't just cheaper — it's the only option for v3-quality transcription.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cantonese accuracy&lt;/strong&gt;: &lt;code&gt;language=yue&lt;/code&gt; with Whisper large-v3-turbo is noticeably better than the managed API for Cantonese&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Privacy&lt;/strong&gt;: audio never leaves your infrastructure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No rate limits&lt;/strong&gt;: Lambda scales independently&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Telegram voice message
        ↓
   OpenClaw (gateway)
        ↓
Lambda Function URL (auth via token)
        ↓
Lambda (VPC, 10GB RAM, 900s timeout)
        ↓
EFS /mnt/whisper-models/{model-slug}
        ↓
faster-whisper (CTranslate2, INT8)
        ↓
    Transcript
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lambda configuration:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Memory: 10,240 MB — actual usage is &lt;strong&gt;~2.2GB&lt;/strong&gt; (INT8 model), but &lt;a href="https://docs.aws.amazon.com/lambda/latest/dg/configuration-memory.html" rel="noopener noreferrer"&gt;Lambda allocates CPU proportional to memory&lt;/a&gt;. 10GB gives ~6 vCPUs vs ~2.3 vCPUs at 4GB, cutting warm transcription from ~16s to ~10s. You're paying for CPU, not RAM.&lt;/li&gt;
&lt;li&gt;Timeout: 900s (handles long audio files)&lt;/li&gt;
&lt;li&gt;VPC: Default VPC (no NAT Gateway)&lt;/li&gt;
&lt;li&gt;EFS: Mounted at &lt;code&gt;/mnt/whisper-models&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Memory vs. cost trade-off (tested, 3 runs each):&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Cold Start&lt;/th&gt;
&lt;th&gt;Warm (2.5s audio)&lt;/th&gt;
&lt;th&gt;GB-seconds/invocation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4,096 MB&lt;/td&gt;
&lt;td&gt;~30s&lt;/td&gt;
&lt;td&gt;~21s&lt;/td&gt;
&lt;td&gt;84 (~$0.00140)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6,144 MB&lt;/td&gt;
&lt;td&gt;~25s&lt;/td&gt;
&lt;td&gt;~16s&lt;/td&gt;
&lt;td&gt;96 (~$0.00160)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8,192 MB&lt;/td&gt;
&lt;td&gt;~24s&lt;/td&gt;
&lt;td&gt;~18s&lt;/td&gt;
&lt;td&gt;144 (~$0.00240)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10,240 MB&lt;/td&gt;
&lt;td&gt;~22s&lt;/td&gt;
&lt;td&gt;~15s&lt;/td&gt;
&lt;td&gt;150 (~$0.00250)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cold start is ~20-30s across all configs — it's EFS I/O bound, not CPU bound, so more memory doesn't help much here. Warm inference time does scale with memory (more vCPUs = faster CTranslate2 decoding). Interestingly, 4GB is the cheapest per invocation — the warm time savings at higher memory don't offset the extra GB-seconds. Within the free tier, cost differences are negligible regardless.&lt;/p&gt;




&lt;h2&gt;
  
  
  API Compatibility
&lt;/h2&gt;

&lt;p&gt;The adaptor exposes two endpoints so it works as a drop-in replacement for existing integrations:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenAI compatible&lt;/strong&gt; (&lt;code&gt;/v1/audio/transcriptions&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-url&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/v1/audio/transcriptions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &amp;lt;secret&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@audio.ogg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"language=yue"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"transcript here"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deepgram compatible&lt;/strong&gt; (&lt;code&gt;/v1/listen&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-url&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/v1/listen?language&lt;span class="o"&gt;=&lt;/span&gt;yue &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &amp;lt;secret&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: audio/ogg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @audio.ogg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Model Management API
&lt;/h2&gt;

&lt;p&gt;Once you've synced multiple models to EFS, there's no SSH access to see what's there or clean up. I added two non-standard endpoints:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;List models on EFS:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl https://&amp;lt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-url&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/v1/models &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &amp;lt;secret&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"list"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai/whisper-large-v3-turbo"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"owned_by"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Systran/faster-distil-whisper-large-v3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"owned_by"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Systran"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Delete a model from EFS&lt;/strong&gt; (the currently loaded model returns 409):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE https://&amp;lt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-url&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/v1/models/Systran/faster-distil-whisper-large-v3 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &amp;lt;secret&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Systran/faster-distil-whisper-large-v3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"object"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"deleted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Slashes in model IDs work naturally — &lt;code&gt;rawPath&lt;/code&gt; preserves the full path, so &lt;code&gt;DELETE /v1/models/openai/whisper-large-v3-turbo&lt;/code&gt; correctly maps to model ID &lt;code&gt;openai/whisper-large-v3-turbo&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Performance Tip: Always Specify Language
&lt;/h2&gt;

&lt;p&gt;When no language is specified, Whisper runs language detection on the first audio chunk — adding noticeable overhead. For a 2.5s voice message:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Request&lt;/th&gt;
&lt;th&gt;Response Time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;No language (auto-detect)&lt;/td&gt;
&lt;td&gt;~22s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;language=yue&lt;/code&gt; (Cantonese)&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's a &lt;strong&gt;2x speedup&lt;/strong&gt; just from passing a language hint. Two ways to do it:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option A — per-request query param&lt;/strong&gt; (recommended, keeps Lambda language-agnostic):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Deepgram endpoint&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-url&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/v1/listen?language&lt;span class="o"&gt;=&lt;/span&gt;yue &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &amp;lt;secret&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: audio/ogg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @audio.ogg

&lt;span class="c"&gt;# OpenAI endpoint&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST https://&amp;lt;&lt;span class="k"&gt;function&lt;/span&gt;&lt;span class="nt"&gt;-url&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/v1/audio/transcriptions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &amp;lt;secret&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"file=@audio.ogg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-F&lt;/span&gt; &lt;span class="s2"&gt;"language=yue"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option B — Lambda env var&lt;/strong&gt; (simpler if you only ever transcribe one language):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;WHISPER_LANGUAGE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;yue
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I use Option A — the language is set in my OpenClaw config (&lt;code&gt;language: "yue"&lt;/code&gt; in the audio model), which passes it as &lt;code&gt;?language=yue&lt;/code&gt; to the Lambda on every request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Real-time Factor
&lt;/h3&gt;

&lt;p&gt;Once warm, the Lambda transcribes faster than real-time for typical voice messages:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Audio Duration&lt;/th&gt;
&lt;th&gt;Warm Response Time&lt;/th&gt;
&lt;th&gt;Real-time Factor&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2.5s&lt;/td&gt;
&lt;td&gt;~10s&lt;/td&gt;
&lt;td&gt;4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;33s&lt;/td&gt;
&lt;td&gt;~23s&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;0.68x&lt;/strong&gt; ✅ faster than real-time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 2.5s result looks slow (4x), but Whisper processes audio in 30-second chunks — the overhead is fixed regardless of audio length. For longer messages, the real-time factor drops well below 1x.&lt;/p&gt;




&lt;h2&gt;
  
  
  Open Source
&lt;/h2&gt;

&lt;p&gt;The project is open source at &lt;a href="https://github.com/gabrielkoo/aws-lambda-whisper-adaptor" rel="noopener noreferrer"&gt;gabrielkoo/aws-lambda-whisper-adaptor&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Key features:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Any &lt;a href="https://huggingface.co/models?search=faster-whisper" rel="noopener noreferrer"&gt;faster-whisper&lt;/a&gt; model via &lt;code&gt;HF_MODEL_REPO&lt;/code&gt; env var&lt;/li&gt;
&lt;li&gt;GitHub Actions workflow to sync models from HuggingFace → S3 (&lt;code&gt;quantization=int8&lt;/code&gt; for HF-format models, &lt;code&gt;quantization=none&lt;/code&gt; for pre-converted CTranslate2 models)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;GET /v1/models&lt;/code&gt; — list all models currently on EFS&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DELETE /v1/models/{owner}/{model}&lt;/code&gt; — remove a model from EFS on demand&lt;/li&gt;
&lt;li&gt;Pre-built Docker image: &lt;code&gt;ghcr.io/gabrielkoo/aws-lambda-whisper-adaptor:latest&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Configurable language detection via &lt;code&gt;WHISPER_LANGUAGE&lt;/code&gt; env var or per-request parameter&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Pre-warming
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;For OpenClaw voice prompts:&lt;/strong&gt; the ~20-30s cold start is often negligible in practice — if you're asking the agent to run a multi-step job, it'll take a few minutes anyway. Pre-warming only matters if you need the very first transcription to be fast.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Cold starts happen when Lambda hasn't been invoked recently. For predictable usage patterns (e.g. a morning standup bot), pre-warm the Lambda before you need it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
&lt;span class="c"&gt;# prewarm.sh — trigger Lambda init before expected usage&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$WHISPER_LAMBDA_URL&lt;/span&gt;&lt;span class="s2"&gt;/v1/listen?language=yue"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Token &lt;/span&gt;&lt;span class="nv"&gt;$WHISPER_API_SECRET&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: audio/ogg"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data-binary&lt;/span&gt; @sample.ogg
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Lambda pre-warmed"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Schedule with cron: &lt;code&gt;0 8 * * * /path/to/prewarm.sh&lt;/code&gt; (runs at 8am daily).&lt;/p&gt;

&lt;p&gt;Alternatively, use an EventBridge rule to ping the Lambda every few minutes — though at that frequency, Provisioned Concurrency starts making more sense cost-wise.&lt;/p&gt;




&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The Lambda + EFS + S3 architecture achieves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~20-30s cold start&lt;/strong&gt; (INT8 model on EFS); first-ever bootstrap from S3 takes ~55s (one-time only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~10s warm invocations&lt;/strong&gt; with &lt;code&gt;language=yue&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;~$0.21/month&lt;/strong&gt; storage cost&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero idle cost&lt;/strong&gt; (scales to zero)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deepgram and OpenAI compatible&lt;/strong&gt; APIs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key insight: &lt;strong&gt;EFS is the missing piece&lt;/strong&gt;. It provides persistent, fast storage that Lambda can access without a NAT Gateway (using the free S3 VPC Gateway Endpoint for bootstrapping).&lt;/p&gt;

&lt;p&gt;I couldn't find any existing write-up of Whisper on Lambda using EFS for persistent model caching — most approaches either bundle the model in Docker (3-minute cold starts) or re-download from S3 on every cold start (no caching between instances). If you've seen this done before, I'd love to know.&lt;/p&gt;

&lt;p&gt;Two things worth knowing before you deploy:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Use &lt;code&gt;Zoont/faster-whisper-large-v3-turbo-int8-ct2&lt;/code&gt; with &lt;code&gt;quantization=none&lt;/code&gt; in the sync-model workflow — it's pre-converted to CTranslate2 INT8 and works out of the box (the &lt;code&gt;openai/whisper-large-v3-turbo&lt;/code&gt; model requires conversion and can hit &lt;code&gt;num_mel_bins&lt;/code&gt; config issues)&lt;/li&gt;
&lt;li&gt;Always pass a &lt;code&gt;language&lt;/code&gt; parameter if you know it — cuts response time roughly in half&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're building voice transcription on AWS and want Whisper-quality accuracy without the SageMaker complexity, give it a try.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Using EFS as a persistent model cache follows the same pattern I used earlier for &lt;a href="https://dev.to/aws-builders/scale-a-stateful-streamlit-chatbot-with-aws-ecs-and-efs-48gm"&gt;scaling a stateful Streamlit chatbot with ECS + EFS&lt;/a&gt; — if you're building other stateful workloads on AWS, that one's worth a look too.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>whisper</category>
      <category>openclaw</category>
      <category>aws</category>
      <category>efs</category>
    </item>
    <item>
      <title>I Squeezed My $1k Monthly OpenClaw API Bill with ~$20/Month in AWS Credits — Here's the Exact Setup</title>
      <dc:creator>Gabriel Koo</dc:creator>
      <pubDate>Sat, 21 Feb 2026 14:44:45 +0000</pubDate>
      <link>https://dev.to/aws-builders/i-squeezed-my-1k-monthly-openclaw-api-bill-with-20month-in-aws-credits-heres-the-exact-setup-3gj4</link>
      <guid>https://dev.to/aws-builders/i-squeezed-my-1k-monthly-openclaw-api-bill-with-20month-in-aws-credits-heres-the-exact-setup-3gj4</guid>
      <description>&lt;p&gt;Update (May 1, 2026): AWS Finally started cracking down this technique. &lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;New signups will no longer available starting &lt;strong&gt;&lt;u&gt;May 15, 2026&lt;/u&gt;&lt;/strong&gt;. New Q Developer Free Tier account creation (via Builder ID in IDE plugins) and new Q Developer subscription creation (via the AWS Console) will be blocked.&lt;br&gt;
Model changes: starting &lt;strong&gt;&lt;u&gt;May 29, 2026&lt;/u&gt;&lt;/strong&gt;, Opus 4.6 will no longer be available on Q Developer Pro. Opus 4.5 and all other existing models remain available. The latest coding models, including Opus 4.7, are available exclusively on Kiro.&lt;br&gt;
Existing customers retain access. If you access Q Developer via a Q Developer Pro subscription or Kiro subscription, you will continue to have access to the Q Developer IDE plugins until &lt;strong&gt;&lt;u&gt;April 30, 2027&lt;/u&gt;&lt;/strong&gt;. The May 15, 2026 change affects new signups for Q Developer accounts and subscriptions.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/" rel="noopener noreferrer"&gt;https://aws.amazon.com/blogs/devops/amazon-q-developer-end-of-support-announcement/&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Part 1 of my series on building a low-cost personal AI stack on AWS.&lt;/em&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://dev.to/aws-builders/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9"&gt;Part 2 — Drop-in Perplexity Sonar replacement with AWS Bedrock Nova Grounding&lt;/a&gt;&lt;/em&gt;&lt;br&gt;
&lt;em&gt;&lt;a href="https://dev.to/aws-builders/from-3-minute-cold-starts-to-20-seconds-whisper-on-aws-lambda-efs-for-openclaw-9c5"&gt;Part 3 — From 3-Minute Cold Starts to ~20 Seconds: Whisper on AWS Lambda + EFS&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;



&lt;p&gt;I've got OpenClaw(MoltBot(ClawdBot)) running locally on a Raspberry Pi — where computation power is scarce, and it's gone unresponsive on me more than a few times. But even on constrained hardware, every chat turn, every memory search, every web lookup is hitting paid APIs. The bill is small at first, then it isn't. I had been using &lt;code&gt;qwen3-coder-480b&lt;/code&gt; for a week or two, and the daily cost skyrocketed to as much as $50.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Assumption:&lt;/strong&gt; OpenClaw is running on hardware you already own or pay for separately — a Raspberry Pi, home server, or existing cloud instance. The compute cost of the host itself isn't counted in the ~$20/month figure here.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If you've picked up AWS Credits from events, the &lt;a href="https://builder.aws.com/content/32g2lQ7kc3Py8kKIYGS15Pe8VSS/aws-community-builders-program" rel="noopener noreferrer"&gt;AWS Community Builder program&lt;/a&gt; ($500/year), or AWS Activate — or if your company prefers to keep spend within AWS rather than onboarding yet another SaaS API provider — there's a way to run the whole OpenClaw stack on credits.&lt;/p&gt;

&lt;p&gt;This is how I did it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Disclaimer&lt;/strong&gt;: The crux of this hack relies heavily on Amazon Q Developer Pro's undocumented while generously high usage ceiling while it lasts. If it's eventually deprecated, we will still need to switch to Kiro plans with overage pricings - still covered by AWS Credits with lower cost/token ratio. &lt;/p&gt;
&lt;/blockquote&gt;


&lt;h2&gt;
  
  
  Who This Is For
&lt;/h2&gt;

&lt;p&gt;Two very different reasons to care about this setup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you have AWS Credits to burn:&lt;/strong&gt;&lt;br&gt;
Credits from re:Invent, AWS Community Builder, AWS Activate, or customer programs come with expiry dates. Running your AI assistant stack on them is one of the most practical ways to put idle credits to work — at ~$20/month, $100 in credits covers 5 months of the full stack. If you're sitting on a few hundred dollars with an end-of-year deadline, this is a productive use before they lapse.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If you're in a company with procurement or compliance requirements:&lt;/strong&gt;&lt;br&gt;
Every new SaaS vendor is a TPRM exercise. OpenAI for embeddings, Perplexity for web search, Anthropic for Claude — each one is a separate vendor assessment, a separate DPA, and a separate conversation with your security team. For FSI and regulated industries, that's not just overhead — it can be a blocker.&lt;/p&gt;

&lt;p&gt;AWS is likely already in your vendor register. Consolidating on Bedrock means single billing, fewer third-party relationships to manage, and data residency you control. For anything touching customer data in banking, insurance, or healthcare, that's the difference between a quick internal approval and a 3-month procurement cycle.&lt;/p&gt;


&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS account&lt;/strong&gt; with Bedrock access enabled in &lt;code&gt;us-east-1&lt;/code&gt; (or another US region)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS credentials&lt;/strong&gt; — a &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/api-keys.html" rel="noopener noreferrer"&gt;Bedrock API key&lt;/a&gt; is the simplest option if your account supports it. Otherwise, a long-term IAM access key/secret key pair works fine and is easier to manage than SSO. IAM Identity Center is only required for the Q Developer Pro layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python 3.10+&lt;/strong&gt; — used by kiro-gateway, LiteLLM, and the Nova grounding proxy&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Amazon Q Developer Pro subscription&lt;/strong&gt; ($19/user/month, credit-eligible) — required for Layer 1 (kiro-gateway). Kiro Pro, Pro+, or Power plans also work but are credit-based with overage charges — Q Developer Pro is the better deal.&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  What Actually Costs Money in OpenClaw?
&lt;/h2&gt;

&lt;p&gt;Before reaching for solutions, it helps to know exactly where the spend goes. OpenClaw has five distinct cost centers:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Main model (LLM)&lt;/strong&gt;&lt;br&gt;
Every chat turn, every agent action, every tool call — all routed through your primary LLM. This is the biggest variable cost. On a busy day it adds up fast.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Memory search (embeddings)&lt;/strong&gt;&lt;br&gt;
OpenClaw's &lt;code&gt;memory_search&lt;/code&gt; tool converts your memory files into vector embeddings and queries them semantically. Every search = an embedding API call. Low cost per call, but it runs constantly in the background.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Web search&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;web_search&lt;/code&gt; tool hits Perplexity or Brave APIs. Perplexity charges per query on paid plans; Brave gives you $5/month free then charges beyond that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Browser automation&lt;/strong&gt;&lt;br&gt;
The &lt;code&gt;browser&lt;/code&gt; tool spins up a Chromium instance for web scraping, form filling, and screenshots. Running a full browser on a low-compute machine (Raspberry Pi, t4g.small) is heavy — and cloud browser options cost per session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Speech-to-text (STT)&lt;/strong&gt;&lt;br&gt;
Voice messages transcribed via your STT provider. OpenAI Whisper API charges per minute of audio — self-hosting on Lambda eliminates this entirely.&lt;/p&gt;

&lt;p&gt;That's it. Five layers. The goal: drive variable cost to zero.&lt;/p&gt;


&lt;h2&gt;
  
  
  My Config: All 5 Layers on AWS Credits
&lt;/h2&gt;

&lt;p&gt;Here's the full picture before we go deep:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;th&gt;Credit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Main model&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/jwadow/kiro-gateway" rel="noopener noreferrer"&gt;kiro-gateway&lt;/a&gt; → Amazon Q Developer Pro&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/Jwadow" rel="noopener noreferrer"&gt;@Jwadow&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Memory search&lt;/td&gt;
&lt;td&gt;Native Bedrock embeddings via &lt;a href="https://github.com/openclaw/openclaw/pull/20191" rel="noopener noreferrer"&gt;PR #20191&lt;/a&gt;
&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/gabrielkoo" rel="noopener noreferrer"&gt;@gabrielkoo&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Web search&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/gabrielkoo/bedrock-web-search-proxy" rel="noopener noreferrer"&gt;bedrock-web-search-proxy&lt;/a&gt; — Nova Grounding as Perplexity drop-in&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/gabrielkoo" rel="noopener noreferrer"&gt;@gabrielkoo&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/vercel-labs/agent-browser/pull/397" rel="noopener noreferrer"&gt;agent-browser + AgentCore provider&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;&lt;a href="https://x.com/pahudnet" rel="noopener noreferrer"&gt;@pahudnet&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speech-to-text&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/gabrielkoo/aws-lambda-whisper-adaptor" rel="noopener noreferrer"&gt;&lt;code&gt;aws-lambda-whisper-adaptor&lt;/code&gt;&lt;/a&gt; — Whisper on Lambda + EFS&lt;/td&gt;
&lt;td&gt;&lt;a href="https://github.com/gabrielkoo" rel="noopener noreferrer"&gt;@gabrielkoo&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three of these I built myself. Two were built by other community members. All five are open source.&lt;/p&gt;


&lt;h2&gt;
  
  
  Layer 1: Main Model + Image Analysis — Kiro CLI — Covered by AWS Credits
&lt;/h2&gt;
&lt;h3&gt;
  
  
  Amazon Q Developer Pro: flat-rate access to Claude
&lt;/h3&gt;

&lt;p&gt;The key difference between Amazon Q Developer Pro and Kiro Pro is the billing model. Kiro Pro is credit-based — 1,000 credits/month, pay more if you exceed them. Amazon Q Developer Pro is a flat monthly subscription: &lt;strong&gt;$19/user/month, no per-token billing, no surprise overages.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Usage&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kiro Free&lt;/td&gt;
&lt;td&gt;$0/mo&lt;/td&gt;
&lt;td&gt;50 credits/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kiro Pro&lt;/td&gt;
&lt;td&gt;$20/mo&lt;/td&gt;
&lt;td&gt;1,000 credits + $0.04/credit overage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kiro Pro+&lt;/td&gt;
&lt;td&gt;$40/mo&lt;/td&gt;
&lt;td&gt;2,000 credits + $0.04/credit overage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kiro Power&lt;/td&gt;
&lt;td&gt;$200/mo&lt;/td&gt;
&lt;td&gt;10,000 credits + $0.04/credit overage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Q Developer Pro (legacy)&lt;/td&gt;
&lt;td&gt;$19/user/mo&lt;/td&gt;
&lt;td&gt;Flat-rate, not credit-capped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Amazon Q Developer Pro is now a legacy plan in the Kiro ecosystem. AWS has stopped allowing new Builder ID subscriptions to Q Developer Pro — new users can only subscribe through Kiro plans. The undocumented usage limits on Q Pro are likely part of why AWS made this transition. If you're already on Q Developer Pro, you retain access and it remains the better deal for OpenClaw.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Your Q Developer Pro subscription grants access to &lt;code&gt;kiro-cli&lt;/code&gt;. The documented quota is &lt;a href="https://docs.aws.amazon.com/general/latest/gr/amazonqdev.html" rel="noopener noreferrer"&gt;10,000 inference calls/month&lt;/a&gt; — for a personal AI assistant, that's more than enough.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Real-world cost check:&lt;/strong&gt; In 4 days of active OpenClaw usage after switching to kiro-gateway, I consumed ~40M input tokens and ~865K output tokens with Claude Sonnet. OpenClaw loads memory files, system prompts, and tool results into every turn — the context window fills up fast. At standard Bedrock pricing ($3/1M input, $15/1M output), that's ~$135 for 4 days, or roughly &lt;strong&gt;$1,000/month&lt;/strong&gt;. Q Developer Pro covers all of it for $19/month flat.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In practice, I've been running Kiro CLI with OpenClaw daily and haven't hit any rate limits in active use. Note: the &lt;code&gt;/usage&lt;/code&gt; command isn't available under the Q Developer Pro plan — monitor your usage via the AWS console instead. That said, after running OpenClaw with kiro-gateway for several days, I checked the Q Developer usage metrics in the AWS console and the figures hadn't moved at all. It's unclear whether Kiro CLI usage is counted against the same quota as Q Developer's agentic requests, or tracked separately. The &lt;a href="https://aws.amazon.com/q/developer/pricing/" rel="noopener noreferrer"&gt;Amazon Q Developer pricing page&lt;/a&gt; only states "Included (with limits)" for the Pro tier — no specifics on what those limits are or how Kiro CLI calls are metered.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; Q Developer Pro requires &lt;a href="https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html" rel="noopener noreferrer"&gt;AWS IAM Identity Center&lt;/a&gt; (SSO) — you can't use it with a free Builder ID. If you're already set up with Identity Center (common in enterprise teams and AWS Community Builders with corporate accounts), you're good to go.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; Standard AWS Credits don't cover per-token Claude usage via Anthropic's marketplace agreement. But the Q Developer Pro subscription fee itself &lt;strong&gt;is&lt;/strong&gt; credit-eligible — making the whole stack fundable with AWS credits. Kiro's flat-rate subscription is currently the only practical way to run Claude in OpenClaw without per-token billing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;New AWS accounts:&lt;/strong&gt; Even if you'd prefer to pay per-token via direct Bedrock API, new accounts often come with &lt;a href="https://dev.to/aws-builders/ultra-low-bedrock-llm-rate-limits-for-new-aws-accounts-time-to-wake-up-your-inactive-aws-accounts-3no0"&gt;ultra-low default rate limits&lt;/a&gt; that can't reliably serve OpenClaw — even when you're willing to pay. The flat-rate Q Developer Pro route sidesteps this entirely.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3&gt;
  
  
  kiro-gateway: the bridge
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/jwadow/kiro-gateway" rel="noopener noreferrer"&gt;kiro-gateway&lt;/a&gt; — built by &lt;a href="https://github.com/Jwadow" rel="noopener noreferrer"&gt;@Jwadow&lt;/a&gt; — wraps Kiro CLI and exposes OpenAI-compatible and Anthropic-compatible API endpoints. OpenClaw talks to it like any other provider.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/jwadow/kiro-gateway
&lt;span class="nb"&gt;cd &lt;/span&gt;kiro-gateway
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt
&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Edit &lt;code&gt;.env&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PROXY_API_KEY="your-secret-key"
KIRO_CREDS_FILE="~/.aws/sso/cache/kiro-auth-token.json"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;kiro-cli login&lt;/code&gt; once to authenticate — this populates &lt;code&gt;KIRO_CREDS_FILE&lt;/code&gt; automatically. (&lt;code&gt;kiro-cli&lt;/code&gt; is only needed for this initial login; &lt;code&gt;kiro-gateway&lt;/code&gt; reads the token it generates. Re-run if your token expires.) Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python main.py &lt;span class="nt"&gt;--port&lt;/span&gt; 9000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads up:&lt;/strong&gt; kiro-gateway's hardcoded fallback model list may lag behind new Claude releases. If a model isn't showing up at &lt;code&gt;/v1/models&lt;/code&gt;, add it manually to &lt;code&gt;FALLBACK_MODELS&lt;/code&gt; in &lt;code&gt;kiro/config.py&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Available models via Q Developer Pro:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Best for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-sonnet-4.6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;General tasks, coding, writing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-haiku-4.5&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fast, lightweight responses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;claude-opus-4.6&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Complex reasoning, long context&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OpenClaw config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"models"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"providers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"kiro"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:9000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-secret-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"api"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"anthropic-messages"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"agents"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"defaults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kiro/claude-sonnet-4.6"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"imageModel"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"primary"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"kiro/claude-sonnet-4.6"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Bonus:&lt;/strong&gt; kiro-gateway works with any tool that supports OpenAI or Anthropic APIs — not just OpenClaw. To use it with Claude Code: &lt;code&gt;ANTHROPIC_BASE_URL=http://localhost:9000&lt;/code&gt; and &lt;code&gt;ANTHROPIC_API_KEY=your-secret-key&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Layer 2: Memory Search — Bedrock Embeddings — Covered by AWS Credits
&lt;/h2&gt;

&lt;p&gt;OpenClaw's &lt;code&gt;memory_search&lt;/code&gt; needs an embedding model. &lt;a href="https://docs.aws.amazon.com/nova/latest/userguide/nova-embeddings.html" rel="noopener noreferrer"&gt;Amazon Nova Multimodal Embeddings&lt;/a&gt; costs ~$0.00014 per 1K tokens — fractions of a cent per query, and covered by AWS Credits.&lt;/p&gt;

&lt;p&gt;OpenClaw's native Bedrock provider doesn't wire up embeddings cleanly yet — &lt;a href="https://github.com/openclaw/openclaw/pull/24892" rel="noopener noreferrer"&gt;PR #24892&lt;/a&gt; - (I made a novice mistake with &lt;a href="https://github.com/openclaw/openclaw/pull/20191" rel="noopener noreferrer"&gt;PR #20191&lt;/a&gt;) is pending merge. Until then, you'll need a local OpenAI-compatible proxy in front of Bedrock. Two options:&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: LiteLLM
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# litellm_config.yaml&lt;/span&gt;
&lt;span class="na"&gt;model_list&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;model_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nova-2-multimodal-embeddings-v1.0&lt;/span&gt;
    &lt;span class="na"&gt;litellm_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bedrock/amazon.nova-2-multimodal-embeddings-v1:0&lt;/span&gt;
      &lt;span class="na"&gt;aws_region_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;us-east-1&lt;/span&gt;

&lt;span class="na"&gt;litellm_settings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;drop_params&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;master_key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;local-only"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s1"&gt;'litellm[proxy]'&lt;/span&gt;
litellm &lt;span class="nt"&gt;--config&lt;/span&gt; litellm_config.yaml &lt;span class="nt"&gt;--port&lt;/span&gt; 4000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"memorySearch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"remote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:4000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"local-only"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"nova-2-multimodal-embeddings-v1.0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option B: bedrock-access-gateway-function-url (serverless, no fixed cost)
&lt;/h3&gt;

&lt;p&gt;My own fork of the original &lt;code&gt;bedrock-access-gateway&lt;/code&gt; — deployed as a Lambda Function URL instead of ALB+Fargate, so there's no $16+/month fixed cost. Full writeup: &lt;a href="https://dev.to/aws-builders/use-amazon-bedrock-models-via-an-openai-api-compatible-serverless-endpoint-now-without-fixed-cost-5hf5"&gt;Use Amazon Bedrock Models with OpenAI SDKs with a Serverless Proxy Endpoint&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; My &lt;a href="https://github.com/aws-samples/bedrock-access-gateway/pull/222" rel="noopener noreferrer"&gt;PR #222&lt;/a&gt; for Nova 2 embedding support against the original &lt;code&gt;bedrock-access-gateway&lt;/code&gt; project has been merged — so my fork pulls from this upstream automatically via &lt;code&gt;prepare_source.sh&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 https://github.com/gabrielkoo/bedrock-access-gateway-function-url
&lt;span class="nb"&gt;cd &lt;/span&gt;bedrock-access-gateway-function-url
./prepare_source.sh
sam build
sam deploy &lt;span class="nt"&gt;--guided&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Grab the &lt;code&gt;FunctionUrl&lt;/code&gt; output after deploy, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"memorySearch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"openai"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"remote"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://&amp;lt;your-function-url&amp;gt;.lambda-url.us-east-1.on.aws"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-api-key"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"amazon.nova-2-multimodal-embeddings-v1:0"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Region note:&lt;/strong&gt; &lt;code&gt;amazon.nova-2-multimodal-embeddings-v1:0&lt;/code&gt; availability varies — check the &lt;a href="https://docs.aws.amazon.com/bedrock/latest/userguide/models-regions.html" rel="noopener noreferrer"&gt;Bedrock model availability page&lt;/a&gt;. Make sure your IAM credentials have &lt;code&gt;bedrock:InvokeModel&lt;/code&gt; in your target region.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Once &lt;a href="https://github.com/openclaw/openclaw/pull/24892" rel="noopener noreferrer"&gt;PR #24892&lt;/a&gt; merges, no proxy needed — the config simplifies to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"memorySearch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"enabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"bedrock"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"amazon.nova-2-multimodal-embeddings-v1:0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"region"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Layer 3: Web Search — Nova Grounding Proxy — Covered by AWS Credits
&lt;/h2&gt;

&lt;p&gt;I built &lt;a href="https://github.com/gabrielkoo/bedrock-web-search-proxy" rel="noopener noreferrer"&gt;&lt;code&gt;bedrock-web-search-proxy&lt;/code&gt;&lt;/a&gt; — a FastAPI wrapper that makes Bedrock Nova Grounding look like the Perplexity Sonar API. No Perplexity or Brave API key needed. Runs entirely on AWS Credits.&lt;/p&gt;

&lt;p&gt;Full writeup: &lt;a href="https://dev.to/aws-builders/drop-in-perplexity-sonar-replacement-with-aws-bedrock-nova-grounding-35o9"&gt;Drop-in Perplexity Sonar Replacement with AWS Bedrock Nova Grounding&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Option A: Run locally
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/gabrielkoo/bedrock-web-search-proxy
&lt;span class="nb"&gt;cd &lt;/span&gt;bedrock-web-search-proxy
pip &lt;span class="nb"&gt;install &lt;/span&gt;fastapi uvicorn boto3
uvicorn main:app &lt;span class="nt"&gt;--port&lt;/span&gt; 7000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Option B: Lambda Function URL (zero idle cost)
&lt;/h3&gt;

&lt;p&gt;See the &lt;a href="https://github.com/gabrielkoo/bedrock-web-search-proxy" rel="noopener noreferrer"&gt;deployment guide in the repo&lt;/a&gt; — SAM-based, arm64, python3.13. Once deployed, you get a persistent HTTPS endpoint with no local process to manage.&lt;/p&gt;

&lt;p&gt;OpenClaw config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tools"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"web"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"search"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"provider"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"perplexity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"perplexity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"apiKey"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your-proxy-key"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"baseUrl"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:7000/v1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sonar-pro"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;All US Nova CRIS (Cross-Region Inference Services) profiles support web grounding (&lt;code&gt;us.amazon.nova-premier-v1:0&lt;/code&gt;, &lt;code&gt;us.amazon.nova-pro-v1:0&lt;/code&gt;, etc.). Native model IDs without the &lt;code&gt;us.&lt;/code&gt; prefix do NOT work — must use CRIS profiles. Web grounding is US regions only (us-east-1, us-east-2, us-west-2).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Layer 4: Cloud Browser — Bedrock AgentCore — Covered by AWS Credits
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/vercel-labs/agent-browser" rel="noopener noreferrer"&gt;&lt;code&gt;agent-browser&lt;/code&gt;&lt;/a&gt; by Vercel Labs, with the AgentCore provider contributed by &lt;a href="https://github.com/pahud" rel="noopener noreferrer"&gt;Pahud Hsieh&lt;/a&gt; (&lt;a href="https://x.com/pahudnet" rel="noopener noreferrer"&gt;@pahudnet&lt;/a&gt;) — &lt;a href="https://github.com/vercel-labs/agent-browser/pull/397" rel="noopener noreferrer"&gt;PR #397&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;The browser runs in AWS — no local Chromium needed. Particularly useful on low-compute instances (Pi, t4g.small) where running a local browser would be too heavy. Covered by AWS Credits.&lt;/p&gt;

&lt;p&gt;Node.js and pnpm required. Since &lt;a href="https://github.com/vercel-labs/agent-browser/pull/397" rel="noopener noreferrer"&gt;PR #397&lt;/a&gt; isn't merged yet, check out the branch directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/vercel-labs/agent-browser
&lt;span class="nb"&gt;cd &lt;/span&gt;agent-browser
git fetch origin pull/397/head:agentcore
git checkout agentcore
pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pnpm build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then use it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;agent-browser &lt;span class="nt"&gt;-p&lt;/span&gt; agentcore open https://example.com
agent-browser close
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your AWS identity needs these IAM permissions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;bedrock-agentcore:StartBrowserSession&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bedrock-agentcore:ConnectBrowserAutomationStream&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;bedrock-agentcore:StopBrowserSession&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;On a desktop machine with enough RAM, local CDP (OpenClaw's built-in browser) is free and works fine. AgentCore is the play for headless/low-compute setups.&lt;/p&gt;
&lt;/blockquote&gt;







&lt;h2&gt;
  
  
  Layer 5: Speech-to-Text — Whisper on Lambda — Covered by AWS Credits
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://github.com/gabrielkoo/aws-lambda-whisper-adaptor" rel="noopener noreferrer"&gt;&lt;code&gt;aws-lambda-whisper-adaptor&lt;/code&gt;&lt;/a&gt; — self-hosted &lt;a href="https://github.com/SYSTRAN/faster-whisper" rel="noopener noreferrer"&gt;faster-whisper&lt;/a&gt; on AWS Lambda, with Deepgram-compatible and OpenAI-compatible transcription endpoints. EFS-backed model storage, pay-per-use, scales to zero.&lt;/p&gt;

&lt;p&gt;Full setup guide: &lt;a href="https://dev.to/aws-builders/from-3-minute-cold-starts-to-20-seconds-whisper-on-aws-lambda-efs-for-openclaw-9c5"&gt;From 3-Minute Cold Starts to ~20 Seconds: Whisper on AWS Lambda + EFS&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Quick Start
&lt;/h3&gt;

&lt;p&gt;Use the pre-built image — no build step needed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker pull ghcr.io/gabrielkoo/aws-lambda-whisper-adaptor:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use this image URI when creating your Lambda function. The repo includes a SAM template for the full VPC + EFS setup.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Lambda runs in VPC for EFS access — no NAT Gateway needed (free S3 VPC Gateway Endpoint). Cold start is ~20–30s on first invocation after a model download; subsequent calls are fast.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Cost Math
&lt;/h2&gt;

&lt;p&gt;Without this setup, Claude Sonnet alone runs ~&lt;strong&gt;$1,000/month&lt;/strong&gt; at standard Bedrock pricing — based on real token usage from my own sessions. OpenClaw's large context window (memory files, system prompts, tool results loaded every turn) means the token bill compounds fast.&lt;/p&gt;

&lt;p&gt;The full stack with this setup runs at &lt;strong&gt;~$20/month&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;$19/mo&lt;/strong&gt; — Amazon Q Developer Pro (flat-rate, covers all LLM calls)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;≤$1/mo&lt;/strong&gt; — Bedrock embeddings for memory search (Nova 2 at $0.00014/1K tokens)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Web search, browser automation, and speech-to-text are covered by AWS Credits — no separate line item.&lt;/p&gt;

&lt;p&gt;With &lt;strong&gt;$100 in AWS Credits&lt;/strong&gt;, you cover roughly &lt;strong&gt;5 months&lt;/strong&gt; of the full stack. Both the Q Developer Pro subscription and Bedrock embeddings are credit-eligible — if you're an AWS Community Builder, that $500/year allocation more than covers it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where AWS Credits Come From
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS event participant/speaker&lt;/strong&gt; — re:Invent, Summit, local user groups&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Community Builder&lt;/strong&gt; — $500/year for active builders (&lt;a href="https://builder.aws.com/content/32g2lQ7kc3Py8kKIYGS15Pe8VSS/aws-community-builders-program" rel="noopener noreferrer"&gt;builder.aws.com&lt;/a&gt;). The application opens a few rounds per year — I'm one of the builders in the program.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Customer Council&lt;/strong&gt; — participation typically includes credits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Activate&lt;/strong&gt; (startups) — up to $100K&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AWS Educate / Academy&lt;/strong&gt; — educators and students&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check your balance: &lt;a href="https://console.aws.amazon.com/billing/home#/credits" rel="noopener noreferrer"&gt;console.aws.amazon.com/billing/home#/credits&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;Five layers. Two built by community members, three I built myself. All open source, all running on AWS Credits.&lt;/p&gt;

&lt;p&gt;To be clear: &lt;strong&gt;kiro-gateway is the most crucial piece here.&lt;/strong&gt; &lt;a href="https://github.com/Jwadow" rel="noopener noreferrer"&gt;@Jwadow&lt;/a&gt; built the bridge that makes Claude accessible without per-token billing — I built the embedding proxy, web search proxy, and Whisper gateway to fill the remaining gaps. &lt;a href="https://github.com/gabrielkoo/bedrock-web-search-proxy" rel="noopener noreferrer"&gt;Web search&lt;/a&gt; and &lt;a href="https://github.com/vercel-labs/agent-browser" rel="noopener noreferrer"&gt;cloud browser&lt;/a&gt; (Layers 3 and 4) are purely AWS Credits — no subscription, per-token billing well covered by AWS Credits.&lt;/p&gt;

&lt;p&gt;If you're already an AWS Community Builder or have credits sitting in your account, there's no reason to be paying per-token for a personal AI assistant. Wire it up once, and the stack runs itself.&lt;/p&gt;

&lt;p&gt;Put those credits to work.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>bedrock</category>
      <category>openclaw</category>
      <category>kiro</category>
    </item>
  </channel>
</rss>
