<?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: kt</title>
    <description>The latest articles on DEV Community by kt (@kanywst).</description>
    <link>https://dev.to/kanywst</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%2F3700180%2F04651b63-c6a1-4069-b356-a0f85c17e0bb.png</url>
      <title>DEV Community: kt</title>
      <link>https://dev.to/kanywst</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/kanywst"/>
    <language>en</language>
    <item>
      <title>What Coding Agents Leave in Public Git Repos</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Wed, 04 Nov 2026 13:38:24 +0000</pubDate>
      <link>https://dev.to/kanywst/what-coding-agents-leave-in-public-git-repos-nm1</link>
      <guid>https://dev.to/kanywst/what-coding-agents-leave-in-public-git-repos-nm1</guid>
      <description>&lt;p&gt;I let coding agents write a lot of my commits now. They write what they see, and what they see is my machine: &lt;code&gt;/Users/&amp;lt;me&amp;gt;/src/...&lt;/code&gt; in a README, a project path in a notes file, a session link at the bottom of a commit. None of it is a secret, so no secret scanner flags it. It is still my account name, in public.&lt;/p&gt;

&lt;p&gt;I wanted to know how common that is, so I counted with GitHub search and checked samples by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;GitHub's count&lt;/th&gt;
&lt;th&gt;Real in my sample&lt;/th&gt;
&lt;th&gt;Rough estimate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; with &lt;code&gt;/Users/&amp;lt;name&amp;gt;/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;27,424 files&lt;/td&gt;
&lt;td&gt;105 of 300&lt;/td&gt;
&lt;td&gt;~9,600 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;AGENTS.md&lt;/code&gt; with &lt;code&gt;/Users/&amp;lt;name&amp;gt;/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;26,208 files&lt;/td&gt;
&lt;td&gt;140 of 300&lt;/td&gt;
&lt;td&gt;~12,200 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code project path &lt;code&gt;~/.claude/projects/-Users-&amp;lt;name&amp;gt;-…&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;47,104 files&lt;/td&gt;
&lt;td&gt;122 of 300&lt;/td&gt;
&lt;td&gt;~19,000 files&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commits with a Codex task link&lt;/td&gt;
&lt;td&gt;~50,800&lt;/td&gt;
&lt;td&gt;431 of 500&lt;/td&gt;
&lt;td&gt;~44,000 commits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Commits with a Claude Code session link&lt;/td&gt;
&lt;td&gt;1.8M to 10.8M&lt;/td&gt;
&lt;td&gt;500 of 500&lt;/td&gt;
&lt;td&gt;millions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;"Real" means the path holds an actual account name. I threw out placeholders like &lt;code&gt;/Users/you/&lt;/code&gt;, &lt;code&gt;/Users/username/&lt;/code&gt; and &lt;code&gt;/Users/&amp;lt;user&amp;gt;/&lt;/code&gt;; a third to a half of the hits survived that.&lt;/p&gt;

&lt;p&gt;Caveats:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Code search only sees default branches of indexed repositories, so the file counts are a floor, not a census.&lt;/li&gt;
&lt;li&gt;GitHub's commit count for the session link moved between 1.8 million and 10.8 million in one afternoon. All 500 of the newest results did contain one.&lt;/li&gt;
&lt;li&gt;My samples are the first pages of results, not a random draw.&lt;/li&gt;
&lt;li&gt;No repository or person is named here. Every number is an aggregate.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why each one ends up there
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Agent memory files.&lt;/strong&gt; &lt;code&gt;CLAUDE.md&lt;/code&gt; and &lt;code&gt;AGENTS.md&lt;/code&gt; are where agents keep notes about a project, and agents write what they know: absolute paths to the repo, to a sibling checkout, to a log directory. Those paths start with your home directory, which is your login. The file then gets committed because it is supposed to be shared with the next session.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The project slug.&lt;/strong&gt; Claude Code keeps per-project state under &lt;code&gt;~/.claude/projects/&lt;/code&gt; in a directory named after the absolute path, so &lt;code&gt;/Users/taro/src/app&lt;/code&gt; becomes &lt;code&gt;-Users-taro-src-app&lt;/code&gt;. That slug shows up in notes, logs and debugging output that agents paste into the repository.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session links.&lt;/strong&gt; Some agent setups add a trailer like &lt;code&gt;Claude-Session: https://claude.ai/code/session_…&lt;/code&gt; to each commit, or a task link to the PR body. What the link does publicly is tie a commit to an agent session and, across repositories, tie commits to each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check your own
&lt;/h2&gt;

&lt;p&gt;Your commits with a session link:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh search commits &lt;span class="s2"&gt;"claude.ai/code/session_"&lt;/span&gt; &lt;span class="nt"&gt;--author&lt;/span&gt; &amp;lt;your-github-login&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your public files with your home path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh search code &lt;span class="s2"&gt;"/Users/&amp;lt;your-login&amp;gt;/"&lt;/span&gt; &lt;span class="nt"&gt;--owner&lt;/span&gt; &amp;lt;your-github-login&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In a local clone, including deleted files and commit metadata:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;brew &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--cask&lt;/span&gt; kanywst/tap/kwangmyong
kwangmyong init
kwangmyong scan &lt;span class="nt"&gt;--history&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt;
&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/https%3A%2F%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Frefs%2Fheads%2Fmain%2Farticles%2Fassets%2Fkwangmyong%2Fdemo.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Frefs%2Fheads%2Fmain%2Farticles%2Fassets%2Fkwangmyong%2Fdemo.gif" alt="kwangmyong's terminal UI walking a made-up developer's home path, hostname, name and session link, then blocking an agent's gh pr create" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop it at the source
&lt;/h2&gt;

&lt;p&gt;Scanning finds what is already out. &lt;code&gt;kwangmyong hook claude&lt;/code&gt; stops it before the write: as a Claude Code &lt;code&gt;PreToolUse&lt;/code&gt; hook it blocks &lt;code&gt;git commit&lt;/code&gt;, &lt;code&gt;git push&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt; bodies, file writes and GitHub MCP calls that carry your name, login, email, hostname or home path, and tells the agent why with the value masked. Codex, Gemini CLI, Cursor, Copilot, Windsurf, Kiro and Devin have adapters too.&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;"hooks"&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;"PreToolUse"&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;"matcher"&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|Write|Edit|MultiEdit|NotebookEdit|mcp__github__.*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"hooks"&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;"command"&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;"kwangmyong hook claude"&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;h2&gt;
  
  
  Method
&lt;/h2&gt;

&lt;p&gt;Run with &lt;code&gt;gh api&lt;/code&gt; on 2026-11-04:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;code: &lt;code&gt;"/Users/" filename:CLAUDE.md&lt;/code&gt;, &lt;code&gt;"/Users/" filename:AGENTS.md&lt;/code&gt;, &lt;code&gt;"-Users-" ".claude/projects"&lt;/code&gt;, first 300 results each, text-match fragments checked with a regex, placeholders excluded&lt;/li&gt;
&lt;li&gt;commits: &lt;code&gt;"claude.ai/code/session_"&lt;/code&gt; and &lt;code&gt;"chatgpt.com/codex/tasks"&lt;/code&gt;, newest first, 500 results each, messages checked with a regex&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Source for the tool: &lt;a href="https://github.com/kanywst/kwangmyong" rel="noopener noreferrer"&gt;https://github.com/kanywst/kwangmyong&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>privacy</category>
      <category>security</category>
    </item>
    <item>
      <title>Secret Scanners Look for Credentials. I Wanted One That Looks for Me.</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Wed, 04 Nov 2026 04:39:45 +0000</pubDate>
      <link>https://dev.to/kanywst/secret-scanners-look-for-credentials-i-wanted-one-that-looks-for-me-5adj</link>
      <guid>https://dev.to/kanywst/secret-scanners-look-for-credentials-i-wanted-one-that-looks-for-me-5adj</guid>
      <description>&lt;p&gt;I let coding agents write a lot of my commits now. They write what they see, and what they see is my machine: &lt;code&gt;/Users/&amp;lt;me&amp;gt;/...&lt;/code&gt; in a README, &lt;code&gt;me@my-laptop ~ %&lt;/code&gt; in a pasted test log, a &lt;code&gt;claude.ai/code/session_...&lt;/code&gt; link at the bottom of a PR body.&lt;/p&gt;

&lt;p&gt;None of that is a credential, so secret scanners don't flag it. I wanted one that knows who I am.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/kanywst/kwangmyong" rel="noopener noreferrer"&gt;https://github.com/kanywst/kwangmyong&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Install: &lt;code&gt;brew install --cask kanywst/tap/kwangmyong&lt;/code&gt; or &lt;code&gt;go install github.com/kanywst/kwangmyong/cmd/kwangmyong@latest&lt;/code&gt;
&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/https%3A%2F%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fkwangmyong%2Fdemo.gif%3Fv%3D839e39cb" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fkwangmyong%2Fdemo.gif%3Fv%3D839e39cb" alt="gitleaks finds nothing; kwangmyong's terminal UI walks the made-up developer's home path, hostname, name and session link, its Claude Code hook blocks gh pr create, and a clean repo comes back TARGET DARK" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Same repository, two scanners
&lt;/h2&gt;

&lt;p&gt;The demo is a four-commit repository for a made-up developer, Taro Yamada (login &lt;code&gt;tyamada&lt;/code&gt;, laptop &lt;code&gt;Taros-MacBook-Pro&lt;/code&gt;):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;README.md&lt;/code&gt; mentions &lt;code&gt;/Users/tyamada/src/app/log&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;NOTES.md&lt;/code&gt; mentions &lt;code&gt;~/.claude/projects/-Users-tyamada-src-app/memory&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test.log&lt;/code&gt; has &lt;code&gt;tyamada@Taros-MacBook-Pro app % make test&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;one commit message ends with a Claude Code session URL&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;AUTHORS&lt;/code&gt; with &lt;code&gt;山田 太郎&lt;/code&gt; was added in one commit and deleted in the next&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;gitleaks 8.30.1:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ gitleaks git . --no-banner
INF 3 commits scanned.
INF scanned ~204 bytes (204 bytes) in 36.2ms
INF no leaks found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct. There are no secrets in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What kwangmyong shows
&lt;/h2&gt;

&lt;p&gt;On a terminal, &lt;code&gt;kwangmyong scan --history .&lt;/code&gt; sweeps the repository with a live count, then opens this:&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%2F0u2z6p01ondval9gis8f.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%2F0u2z6p01ondval9gis8f.png" alt="The kwangmyong terminal UI: places on the left, the masked line, what leaks, why it matters, where else the same value is, and the fix on the right" width="800" height="469"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Every value is masked as a prefix and a fingerprint (&lt;code&gt;ty…[b35fc2ee]&lt;/code&gt;), so a screenshot or a CI log is not a new leak. &lt;code&gt;r&lt;/code&gt; reveals values on that screen only. Search only sees masked text, so typing your own name into it finds nothing.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ALSO&lt;/code&gt; lists the other places the same value turns up. &lt;code&gt;FIX&lt;/code&gt; depends on where it is: a file needs an edit, a deleted file or a commit message needs a history rewrite, and &lt;code&gt;p&lt;/code&gt; drafts that rewrite (git filter-repo inputs plus a checklist of what a rewrite cannot reach) without leaving the screen.&lt;/p&gt;

&lt;p&gt;Quit, and the verdict stays in the scrollback:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;
╭────────────────────────────────────────────────────────────────────────────╮
│ ✗ TARGET EXPOSED  6 pieces of intel on you, from 5 places                  │
│   4 to fix in files · 2 baked into history                                 │
│   threat level ■■■■□ HIGH                                                  │
│   swept 3 files · 4 commits · 1 deleted blob                               │
│   looked for 2 names · 1 login · 1 host · home paths · agent session links │
│   values are masked · --reveal shows them                                  │
│   next ▸ kwangmyong plan .  drafts the history rewrite for you to review   │
╰────────────────────────────────────────────────────────────────────────────╯
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A clean repository gets &lt;code&gt;✓ TARGET DARK&lt;/code&gt; with what was swept and what was looked for, so "nothing found" also says how hard it looked. Piped, in CI, or with &lt;code&gt;--format json|sarif&lt;/code&gt;, the same report prints without the UI.&lt;/p&gt;

&lt;h2&gt;
  
  
  It starts from who you are
&lt;/h2&gt;

&lt;p&gt;The profile lives in &lt;code&gt;~/.config/kwangmyong/config.toml&lt;/code&gt;, never in the repository, and &lt;code&gt;kwangmyong init&lt;/code&gt; fills it in from the machine: login, home directory, full name, hostname, global git identity.&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="k"&gt;[&lt;/span&gt;&lt;span class="n"&gt;identity&lt;/span&gt;&lt;span class="k"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;names&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Taro Yamada"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"山田 太郎"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;logins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"tyamada"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;hosts&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"Taros-MacBook-Pro"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each value is expanded into the forms it actually shows up in: &lt;code&gt;tyamada&lt;/code&gt;, &lt;code&gt;t.yamada&lt;/code&gt;, &lt;code&gt;Yamada Taro&lt;/code&gt;, &lt;code&gt;-Users-tyamada-&lt;/code&gt;, &lt;code&gt;Taro's MacBook Pro&lt;/code&gt;. Session links from Claude Code and Codex are flagged without any config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop the agent, not just the commit
&lt;/h2&gt;

&lt;p&gt;The part I use most is the hooks. &lt;code&gt;kwangmyong hook install&lt;/code&gt; sets up pre-commit, commit-msg and pre-push for a repository. For agents, the pre-tool hook gets the tool call before it runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Agent&lt;/th&gt;
&lt;th&gt;Hook&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Code&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kwangmyong hook claude&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Codex CLI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kwangmyong hook codex&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini CLI&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kwangmyong hook gemini&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cursor&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kwangmyong hook cursor&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub Copilot&lt;/td&gt;
&lt;td&gt;&lt;code&gt;kwangmyong hook copilot&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Windsurf, Kiro, Devin&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;kwangmyong hook windsurf&lt;/code&gt;, &lt;code&gt;kiro&lt;/code&gt;, &lt;code&gt;devin&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It checks &lt;code&gt;git commit&lt;/code&gt;, &lt;code&gt;git push&lt;/code&gt;, &lt;code&gt;gh&lt;/code&gt; bodies and body files, file writes into the repository and GitHub MCP writes, and exits &lt;code&gt;2&lt;/code&gt; to block. The agent gets the reason back, masked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✗ TRANSMISSION BLOCKED. kwangmyong stopped this command: it would hand out intel on you.

[HIGH] command gh:3
  3 │ ht…[bf572e0c]'
    │ ^^^^^^^^^^^^^ your agent's session link

fix ▸ use ~/ or repo-relative paths, your public handle instead of a real name,
      the GitHub noreply address as the commit email, and no agent session links.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  CI
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kanywst/kwangmyong@v0&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;identity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.KWANGMYONG_IDENTITY }}&lt;/span&gt;
    &lt;span class="na"&gt;sarif&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kwangmyong.sarif&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The identity comes from a secret as TOML. The Action checks the release binary against its signed checksums, and the SARIF is masked no matter what.&lt;/p&gt;

&lt;h2&gt;
  
  
  Not covered yet
&lt;/h2&gt;

&lt;p&gt;Wikis, Actions logs, Discussions.&lt;/p&gt;

&lt;p&gt;Source and issues: &lt;a href="https://github.com/kanywst/kwangmyong" rel="noopener noreferrer"&gt;https://github.com/kanywst/kwangmyong&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>git</category>
      <category>privacy</category>
      <category>security</category>
    </item>
    <item>
      <title>Ransomware in Japan: 61 of 92 Known Entry Points in 2025 Were VPN Appliances</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Tue, 27 Oct 2026 14:47:29 +0000</pubDate>
      <link>https://dev.to/kanywst/ransomware-in-japan-61-of-92-known-entry-points-in-2025-were-vpn-appliances-49gn</link>
      <guid>https://dev.to/kanywst/ransomware-in-japan-61-of-92-known-entry-points-in-2025-were-vpn-appliances-49gn</guid>
      <description>&lt;p&gt;Japan's National Police Agency surveys ransomware victims about how the attacker got in. In 2025, 92 could say. 61 named their VPN appliance. Two named email.&lt;/p&gt;

&lt;p&gt;Most security training is still about email. I built a site that shows where the intrusions actually started.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Site: &lt;a href="https://0-draft.github.io/vpn-is-the-door/" rel="noopener noreferrer"&gt;https://0-draft.github.io/vpn-is-the-door/&lt;/a&gt; (Japanese: &lt;a href="https://0-draft.github.io/vpn-is-the-door/ja/" rel="noopener noreferrer"&gt;https://0-draft.github.io/vpn-is-the-door/ja/&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Source: &lt;a href="https://github.com/0-draft/vpn-is-the-door" rel="noopener noreferrer"&gt;https://github.com/0-draft/vpn-is-the-door&lt;/a&gt;
&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%2Fgbf1r96tz2o0hzxd45c5.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%2Fgbf1r96tz2o0hzxd45c5.png" alt="Floor plan of a fictional company with one square per case stacked outside each opening: 61 at the VPN door, 19 at the remote desktop window, 10 at the loading door, 2 at the main entrance" width="800" height="714"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One square per case, stacked outside the opening the attacker used.&lt;/p&gt;

&lt;h2&gt;
  
  
  One company, drawn as a floor plan
&lt;/h2&gt;

&lt;p&gt;The site follows a fictional 280-person parts maker. Email comes in through the main entrance and gets checked at reception. The VPN appliance is a door in the outer wall that lets staff in without passing reception; it opens into the corridor behind it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens after the door opens
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://0-draft.github.io/vpn-is-the-door/after-the-door/" rel="noopener noreferrer"&gt;Chapter 2&lt;/a&gt; is a scroll-driven plan: the door opens, Active Directory falls, backups are deleted, data leaves through the same door, and encryption comes last.&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%2Fgcdr1obk68y5os1xe8wm.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%2Fgcdr1obk68y5os1xe8wm.png" alt="Chapter 2 on desktop: step text on the left, the plan on the right with the route from the VPN door to the backup room" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In 2025, 79 of the 99 Japanese victims that tried to restore from backup could not, and 48 found their backups encrypted too. The chapter also maps the published investigation report of an Osaka hospital onto the same steps.&lt;/p&gt;

&lt;h2&gt;
  
  
  31 exploited VPN flaws
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://0-draft.github.io/vpn-is-the-door/the-doors/#fig-3-7" rel="noopener noreferrer"&gt;Chapter 3&lt;/a&gt; lists 31 VPN and remote-access flaws from CISA's Known Exploited Vulnerabilities catalog (FortiGate, Ivanti Connect Secure, Citrix NetScaler, Palo Alto GlobalProtect, SonicWall and others). Selecting a row shows which part of the door it breaks.&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%2F5jj9cg8io9kuj684ly2k.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%2F5jj9cg8io9kuj684ly2k.png" alt="The door schedule: a detail drawing of the door on the left, Fortinet CVEs listed on the right" width="800" height="538"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;29 of the 31 are reachable before login, so MFA never comes into play.&lt;/li&gt;
&lt;li&gt;19 were exploited before the vendor warned anyone.&lt;/li&gt;
&lt;li&gt;For 17, patching alone did not close the door: stolen credentials or sessions stayed valid, or the intruder's implant survived the update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where each control actually helps
&lt;/h2&gt;

&lt;p&gt;The last figure in &lt;a href="https://0-draft.github.io/vpn-is-the-door/what-to-do/#fig-6-5" rel="noopener noreferrer"&gt;chapter 6&lt;/a&gt; lets you pick how the attacker gets in and which controls are on, then shows where the route stops.&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%2F26i4lqgraj1f8d26v4mi.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%2F26i4lqgraj1f8d26v4mi.png" alt="Phone view with a pre-auth flaw selected and MFA plus offline backup on: backups survive, but the leak and encryption still happen" width="390" height="1715"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;MFA does nothing against a pre-auth flaw. An offline backup lets you recover from encryption but does not stop the leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cases, in the organizations' own words
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://0-draft.github.io/vpn-is-the-door/cases/" rel="noopener noreferrer"&gt;Chapter 4&lt;/a&gt; has 15 real incidents, including Asahi Group, ASKUL, three Japanese hospitals and Colonial Pipeline. Each is based only on the organization's own statement or an official investigation report. If an organization wrote "network device", the page says "network device". Nine of the 15 name a VPN appliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the numbers were checked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;NPA figures come from NPA's own statistics workbooks, sheet by sheet. Percentages are computed in one place and unit-tested against NPA's published roundings.&lt;/li&gt;
&lt;li&gt;Every KEV field was checked with &lt;code&gt;jq&lt;/code&gt; against the catalog JSON; the 2026-numbered CVEs were also checked against the NVD API.&lt;/li&gt;
&lt;li&gt;Anything not confirmed in a primary source is left out. The 80 cited documents are listed on the &lt;a href="https://0-draft.github.io/vpn-is-the-door/sources/" rel="noopener noreferrer"&gt;sources page&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No exploit code or attack steps appear anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it's built
&lt;/h2&gt;

&lt;p&gt;Astro 7, static, on GitHub Pages. No UI framework; the interactive figures are plain TypeScript. The floor plan is data, and a test fails if the intruder's route crosses a wall anywhere but a doorway. Search is Pagefind, and the OG images are drawn at build time with satori. CI runs axe, a link check that includes &lt;code&gt;#fragments&lt;/code&gt;, and a Lighthouse budget. A weekly job compares the door schedule with the live KEV catalog.&lt;/p&gt;

&lt;p&gt;If a number or attribution is wrong, open an issue with the source that shows it.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>security</category>
      <category>ransomware</category>
      <category>vpn</category>
    </item>
    <item>
      <title>Mandatum: Making an AI Agent's Authority a Chain You Can Verify</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Wed, 16 Sep 2026 10:45:49 +0000</pubDate>
      <link>https://dev.to/kanywst/mandatum-making-an-ai-agents-authority-a-chain-you-can-verify-1hoa</link>
      <guid>https://dev.to/kanywst/mandatum-making-an-ai-agents-authority-a-chain-you-can-verify-1hoa</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-11-08):&lt;/strong&gt; &lt;code&gt;mandatum&lt;/code&gt; has since been archived at v0.2.0 and is no longer developed. The code and the &lt;a href="https://github.com/kanywst/mandatum/blob/main/docs/spec/delegation-assertion.md" rel="noopener noreferrer"&gt;specification&lt;/a&gt; stay readable, and the open questions below are still worth arguing with, but nothing here will be fixed or extended.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Almost every AI agent acting for a person does it by inheriting a credential: a service account, an API key, a shared token, the user's own session.&lt;/p&gt;

&lt;p&gt;Three things break the moment you do that. Two surveys put a rough number on the first of them: 28% of organizations can reliably trace agent actions to a human or system across all environments (Cloud Security Alliance and Strata Identity, &lt;em&gt;Securing Autonomous AI Agents&lt;/em&gt;, February 2026; n=285), and 68% cannot clearly distinguish AI agent activity from human activity (Cloud Security Alliance and Aembit, &lt;em&gt;Identity and Access Gaps in the Age of Autonomous AI&lt;/em&gt;, March 2026; n=228). Both are vendor-commissioned and self-reported, and neither says "human sponsor" — the first says "human or system".&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;mandatum&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Repo (archived): &lt;a href="https://github.com/kanywst/mandatum" rel="noopener noreferrer"&gt;https://github.com/kanywst/mandatum&lt;/a&gt;&lt;/p&gt;




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

&lt;p&gt;&lt;strong&gt;You cannot say who is responsible.&lt;/strong&gt; The audit log has a service account in it. Which human was that?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot revoke one agent.&lt;/strong&gt; Everything shares the credential, so cutting off the misbehaving agent cuts off everything else. Nobody pulls that lever, so the agent keeps its access.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You cannot constrain a sequence.&lt;/strong&gt; Authorization is decided one call at a time. An agent reads untrusted external content, then writes to an internal system. Both calls are legitimately authorized. The pair is the exfiltration.&lt;/p&gt;

&lt;p&gt;MCP's authorization specification covers the transport: how a client gets a token for a server. It says nothing about which tool that token may call, which agent holds it, or who delegated to whom, and MCP's authorization interest group has open work on per-tool scopes and on consent across chains of agents. The Enterprise-Managed Authorization extension, stable since June 2026, gets an access token from an enterprise identity assertion, so what it produces is scoped to a server rather than to a call.&lt;/p&gt;




&lt;h2&gt;
  
  
  What mandatum does
&lt;/h2&gt;

&lt;p&gt;An agent's authority becomes a signed chain rooted in a named 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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fmandatum-verifiable-delegation%2Fdiagrams%2F01-delegation-and-enforcement.png%3Fv%3Df1355ef4" 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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fmandatum-verifiable-delegation%2Fdiagrams%2F01-delegation-and-enforcement.png%3Fv%3Df1355ef4" alt="Delegation, then enforcement" width="720" height="603"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each link commits to its parent by hash, so links are not interchangeable. Capabilities can only narrow going down. The human at the root is carried unchanged to every leaf, so attribution survives arbitrary sub-delegation. Revoke any link and everything below it dies with it. Nothing else does.&lt;/p&gt;

&lt;p&gt;The library never decides authorization itself. It establishes that an agent holds authority a human delegated, then hands that to an OpenID AuthZEN PDP (OPA, Cedar, OpenFGA, whatever you already run). The request follows the AuthZEN COAZ-MCP binding's default mapping rather than a parallel one I invented — so far for &lt;code&gt;tools/call&lt;/code&gt; only, without the binding's declared mappings or CEL, and with additions of my own that the &lt;a href="https://github.com/kanywst/mandatum/blob/main/docs/spec/coaz-mcp-conformance.md" rel="noopener noreferrer"&gt;conformance document&lt;/a&gt; lists as divergences.&lt;/p&gt;




&lt;h2&gt;
  
  
  The sequence part
&lt;/h2&gt;

&lt;p&gt;The bottom block of that diagram is the bit I actually wanted to build, because per-call authorization structurally cannot do it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;-run&lt;/span&gt; Example ./pkg/sequence/ &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;write first:       allowed
read external:     allowed
write after read:  denied by no-write-after-external-read
sub-agent writes:  denied by no-write-after-external-read
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last line matters. State is keyed by the chain root, not by whoever is acting, so an agent cannot escape an inherited constraint by delegating to a sub-agent it just minted.&lt;/p&gt;

&lt;p&gt;A constraint compiles to one bit (has the trigger fired) plus a counter. The state a chain accumulates is fixed-size however long it runs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Three places I'd actually use it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A coding agent that can open pull requests.&lt;/strong&gt; It reads a dependency's README, an issue comment, a diff from a fork: all attacker-writable. Then it pushes. Tag the reads &lt;code&gt;external-content&lt;/code&gt; and the push &lt;code&gt;mutating&lt;/code&gt;, and the second one stops after the first.&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;"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;"no-push-after-third-party-read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"forbid"&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;"resource.tags"&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;"mutating"&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;"after"&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;"resource.tags"&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;"external-content"&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;A support agent that can issue credits.&lt;/strong&gt; The customer's message is attacker-controlled text, and the refund tool moves money. Same shape, and the sponsor is the support engineer who approved the session, so the audit record names a person rather than &lt;code&gt;svc-support-bot&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A research agent someone left running.&lt;/strong&gt; &lt;code&gt;max_invocations&lt;/code&gt; on the sponsor's grant caps the whole chain, and because state is keyed by the root, spawning ten sub-agents spends the same budget rather than ten of them.&lt;/p&gt;

&lt;p&gt;None of these need a new policy engine. They need the tool call to know what happened earlier under the same authority.&lt;/p&gt;




&lt;h2&gt;
  
  
  What isn't built
&lt;/h2&gt;

&lt;p&gt;There is no audit log yet. The sequence store is in-process only, which means one enforcement point. Two PEPs with separate memories give an agent two histories to spend. Nothing has had a third-party security review.&lt;/p&gt;

&lt;p&gt;All of that is in the &lt;a href="https://github.com/kanywst/mandatum/blob/main/docs/security/threat-model.md" rel="noopener noreferrer"&gt;threat model&lt;/a&gt;, which lists what is not covered rather than only what is.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where I'd like to be wrong
&lt;/h2&gt;

&lt;p&gt;The design leans on a chain rather than a flat list of prior actors. A list answers "who was upstream". A chain also answers "did any hop widen its authority", but that is a lot of machinery, and if the first question turns out to be enough, most of it isn't earning its place.&lt;/p&gt;

&lt;p&gt;Where a chain belongs in an AuthZEN request is open too. The binding carries the acting client in &lt;code&gt;context.agent&lt;/code&gt; and says nothing about the hops above it: it does not rule a chain out of that field and it does not name a place for one, so mandatum keeps the hops under a vendor-prefixed key rather than taking a name inside the binding's namespace. That question is live in &lt;a href="https://github.com/openid/authzen/issues/612" rel="noopener noreferrer"&gt;openid/authzen#612&lt;/a&gt;, a thread on whether &lt;code&gt;context.agent&lt;/code&gt; should be trust-anchored when it comes from the validated token — and where the working group corrected an earlier reading of mine. If you have run agent systems in production and think the chain is overkill, I'd rather hear it now.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://github.com/kanywst/mandatum/blob/main/docs/spec/delegation-assertion.md" rel="noopener noreferrer"&gt;specification&lt;/a&gt; has an open-questions section that is genuinely open.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>go</category>
      <category>security</category>
      <category>authorization</category>
    </item>
    <item>
      <title>I Made Four Authorization Engines Answer the Same Questions</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Sat, 12 Sep 2026 07:11:35 +0000</pubDate>
      <link>https://dev.to/kanywst/i-made-four-authorization-engines-answer-the-same-questions-5a96</link>
      <guid>https://dev.to/kanywst/i-made-four-authorization-engines-answer-the-same-questions-5a96</guid>
      <description>&lt;p&gt;"Which authorization engine should I use" always gets answered with a table. Cedar is analyzable, Rego is expressive, Zanzibar is relationship-based. I could never feel the difference from any of it.&lt;/p&gt;

&lt;p&gt;So I gave four of them the same rules and ran every possible request through all four at once.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo:&lt;/strong&gt; &lt;a href="https://0-draft.github.io/authz-playground/" rel="noopener noreferrer"&gt;https://0-draft.github.io/authz-playground/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/0-draft/authz-playground" rel="noopener noreferrer"&gt;0-draft/authz-playground&lt;/a&gt;
&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%2Fctb7127m10iia8108iq1.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%2Fctb7127m10iia8108iq1.png" alt="One question, every engine, every hour of the day" width="800" height="284"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One question, asked of every engine at every hour of the day. A raised line is allow. Red is where they disagree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where ReBAC stops
&lt;/h2&gt;

&lt;p&gt;The page adds one rule at a time. The owner can edit. Folder admins too. Then: only during business hours.&lt;/p&gt;

&lt;p&gt;That third one is where ReBAC stops keeping up. &lt;code&gt;check(user, relation, object)&lt;/code&gt; has three arguments and none of them holds a clock, so it goes on allowing edits at 3am while the other three deny them. Eight of 72 requests split.&lt;/p&gt;

&lt;p&gt;That gap is why OpenFGA later added Conditions. Their announcement names time of day as the example relationships handle badly.&lt;/p&gt;

&lt;p&gt;The playground now has that fix as a fifth row: the same Zanzibar model with an OpenFGA condition. The grant stays relational, and one more relation gates it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;define within_hours: [user:* with business_hours]
define editor: (owner or admin from parent) and within_hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It agrees with the other three on every request. It is still not free. A condition can only hang off a tuple, and business hours are about the request rather than any relationship, so every document needs a placeholder tuple whose only job is to carry the clock. The page rates it possible but forced, not native.&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%2Fnvpc0qkq29tey77vn55w.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%2Fnvpc0qkq29tey77vn55w.png" alt="The classic model and the conditional one, asked the same question at 08:00" width="800" height="529"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Add a document and Casbin needs its policy table regenerated, while ReBAC just gains a tuple and the model never moves.&lt;/p&gt;

&lt;h2&gt;
  
  
  What broke
&lt;/h2&gt;

&lt;p&gt;The projections are where I could lie without noticing, so they get checked twice: every engine runs every request and any disagreement is reported, and a separate implementation written from the requirement text alone checks they are not all wrong together.&lt;/p&gt;

&lt;p&gt;Both earned their keep.&lt;/p&gt;

&lt;p&gt;I gave the ReBAC model &lt;code&gt;viewer: owner or editor&lt;/code&gt;, because obviously an editor can read too. The requirements never said that. Disagreements doubled and the comparison was quietly dishonest until the harness flagged it.&lt;/p&gt;

&lt;p&gt;Worse, the request enumeration sampled 03:00, 10:00, 14:00 and 22:00, so neither edge of the business-hours window was ever evaluated. I moved one engine's window to 08:00-19:00 to see what would happen. All fourteen tests passed. The suite that existed to protect the comparison could not see the one boundary the whole thing rests on.&lt;/p&gt;

&lt;p&gt;The fix was to sample both edges of the window and the hour either side of them, 08, 09, 17 and 18, plus a test that fails if any of those ever drops out of the enumeration. The 08:00-19:00 window now fails at once.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it runs
&lt;/h2&gt;

&lt;p&gt;No backend. Cedar through its official wasm bindings, Casbin's TypeScript build, and about a hundred lines reimplementing the Zanzibar check algorithm. Rego is OPA itself compiled to &lt;code&gt;js/wasm&lt;/code&gt;, so policies are compiled and evaluated live: 8 MB gzipped, loaded lazily, roughly 2 ms per evaluation after the first.&lt;/p&gt;

&lt;p&gt;If you try that last one, restrict the builtins. I shipped the default set, which left &lt;code&gt;http.send&lt;/code&gt; reachable from pasted Rego. It fired a request from the visitor's browser and deadlocked the Go runtime for the rest of the session.&lt;/p&gt;

&lt;p&gt;Have a go at stage 3 and drag across the chart: &lt;a href="https://0-draft.github.io/authz-playground/" rel="noopener noreferrer"&gt;0-draft.github.io/authz-playground&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>authorization</category>
      <category>security</category>
      <category>webassembly</category>
    </item>
    <item>
      <title>Envoy AI Gateway: A Hands-On Tour You Can Run Before Touching Kubernetes</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Fri, 14 Aug 2026 11:24:30 +0000</pubDate>
      <link>https://dev.to/kanywst/envoy-ai-gateway-a-hands-on-tour-you-can-run-before-touching-kubernetes-136p</link>
      <guid>https://dev.to/kanywst/envoy-ai-gateway-a-hands-on-tour-you-can-run-before-touching-kubernetes-136p</guid>
      <description>&lt;h2&gt;
  
  
  The day we ended up with three places to call an LLM
&lt;/h2&gt;

&lt;p&gt;It started with OpenAI. Then someone pointed out that Bedrock had a cheaper model for one of our workloads, so Bedrock got added. Then a team wanted to use the vLLM instance running on our own GPUs, and that made three.&lt;/p&gt;

&lt;p&gt;At that point the codebase looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Service A called the OpenAI SDK directly&lt;/li&gt;
&lt;li&gt;Service B hit &lt;code&gt;bedrock-runtime&lt;/code&gt; through boto3&lt;/li&gt;
&lt;li&gt;Service C talked to vLLM with a hand-rolled HTTP client&lt;/li&gt;
&lt;li&gt;Three API keys lived in three separate Secrets, each with its own rotation procedure&lt;/li&gt;
&lt;li&gt;Nobody could answer "how many tokens did each team burn last month?"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every one of those code paths does the same thing: send text, get text back. And yet we had three SDKs, three retry policies, three auth schemes, three sets of instrumentation. That is not an application problem. It is the absence of a front door. The HTTP world solved this fifteen years ago with API gateways, and the same thing is happening again with LLM traffic.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://aigateway.envoyproxy.io/" rel="noopener noreferrer"&gt;Envoy AI Gateway&lt;/a&gt; is the project filling that gap, and it hit v1.0 GA on June 23, 2026.&lt;/p&gt;

&lt;p&gt;This article builds up in order: &lt;strong&gt;start with local execution that uses no Kubernetes whatsoever&lt;/strong&gt;, then look at real schema translation, fallback, MCP, and only at the end move to Kubernetes. Read it top to bottom and you can follow along with your hands on the keyboard.&lt;/p&gt;

&lt;p&gt;Every command and every output below came from an actual run. The environment was macOS on arm64, Go 1.26.5, aigw v1.0.0, kind v0.32.0, Envoy Gateway v1.8.1, and Envoy AI Gateway v1.0.0.&lt;/p&gt;




&lt;h2&gt;
  
  
  0. How to read this
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Sections&lt;/th&gt;
&lt;th&gt;Content&lt;/th&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 - 2&lt;/td&gt;
&lt;td&gt;What an AI gateway is, and where it sits in the Envoy family&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 - 4&lt;/td&gt;
&lt;td&gt;Architecture and the CRDs&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;Get it running&lt;/td&gt;
&lt;td&gt;Go or Docker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;Watch schema translation happen&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;Fallback and model name virtualization&lt;/td&gt;
&lt;td&gt;Same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;MCP gateway&lt;/td&gt;
&lt;td&gt;Same, plus Node.js&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;Move it to Kubernetes&lt;/td&gt;
&lt;td&gt;kind / kubectl / helm&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10 - 12&lt;/td&gt;
&lt;td&gt;Token limits, production features, when to adopt&lt;/td&gt;
&lt;td&gt;Nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Sections 5 through 8 need neither Kubernetes nor a cloud API key.&lt;/strong&gt; Everything happens against mock servers running on localhost. If you are short on time, those four sections alone are worth it.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. What makes an "AI gateway" different from a normal API gateway
&lt;/h2&gt;

&lt;p&gt;"Can't you just put nginx or Envoy in front of the LLM?" is a reasonable instinct, and it is about 70 percent right. The other 30 percent is why a dedicated product exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  1-1. The same operation has three different shapes
&lt;/h3&gt;

&lt;p&gt;OpenAI, Anthropic, and Bedrock all support "one round trip of chat," and all three want a different path, a different body, and return a different response shape.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenAI      POST /v1/chat/completions   {"model":..., "messages":[...], "max_tokens":N}
Anthropic   POST /v1/messages           {"model":..., "messages":[...], "max_tokens":N, "system":"..."}
Bedrock     POST /model/&amp;lt;id&amp;gt;/converse   {"messages":[{"role":..,"content":[{"text":..}]}],
                                         "system":[{"text":..}],
                                         "inferenceConfig":{"maxTokens":N}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;max_tokens&lt;/code&gt; becomes &lt;code&gt;inferenceConfig.maxTokens&lt;/code&gt;. The system prompt gets pulled out of &lt;code&gt;messages&lt;/code&gt; and turned into its own top-level array. A normal reverse proxy can rewrite paths and headers, but &lt;strong&gt;restructuring a JSON body&lt;/strong&gt; is outside its job description.&lt;/p&gt;

&lt;p&gt;This is the least glamorous and most valuable thing an AI gateway does. Section 6 shows it happening for real.&lt;/p&gt;

&lt;h3&gt;
  
  
  1-2. Requests are the wrong unit for billing
&lt;/h3&gt;

&lt;p&gt;For a normal API, "100 requests per second" is a fine limit. For an LLM it barely means anything. A 10-token request and a 100,000-token request both count as 1. The cost differs by four orders of magnitude.&lt;/p&gt;

&lt;p&gt;Worse, &lt;strong&gt;you do not know the consumption until the response comes back&lt;/strong&gt;. At request time the output token count does not exist yet. You need a pay-later model: approve first, bill afterward. That is a different premise than any rate limiter was built on.&lt;/p&gt;

&lt;h3&gt;
  
  
  1-3. Responses stream for a long time
&lt;/h3&gt;

&lt;p&gt;An SSE connection stays open for tens of seconds to several minutes. Default timeouts, buffer sizes, whether a retry is even safe. All of it drifts from HTTP API common sense.&lt;/p&gt;

&lt;p&gt;Here are those three problems in one picture. On the left, every application carries its own translation, key management, and instrumentation. On the right, that moves into the gateway.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F01-before-after-gateway.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F01-before-after-gateway.png" alt="Before and after putting a gateway in front" width="800" height="736"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The reduction in lines matters less than the fact that &lt;strong&gt;capabilities disappeared from the application boxes&lt;/strong&gt;. Getting API keys out of applications is the single biggest operational win.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Where this sits in the Envoy family
&lt;/h2&gt;

&lt;p&gt;Three products with confusingly similar names stack on top of each other, so let me sort that out first.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F02-envoy-family-layers.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F02-envoy-family-layers.png" alt="The three layers of the Envoy family" width="800" height="939"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Envoy Proxy&lt;/strong&gt; carries the data. On its own it needs either a config file or xDS.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Envoy Gateway&lt;/strong&gt; is the management layer that reads Kubernetes Gateway API resources and turns them into Envoy config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Envoy AI Gateway&lt;/strong&gt; adds AI-specific CRDs on top and reaches into xDS through an Envoy Gateway extension point called the &lt;em&gt;extension server&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Envoy AI Gateway does not &lt;strong&gt;replace&lt;/strong&gt; Envoy Gateway. It sits on top. If you already run Envoy Gateway you can bolt this on, but the flip side is that Envoy Gateway concepts become prerequisites.&lt;/p&gt;

&lt;p&gt;If you have never touched Gateway API, these four are enough to follow along.&lt;/p&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;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GatewayClass&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which implementation to use. The &lt;code&gt;ingressClass&lt;/code&gt; equivalent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Gateway&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Which port to listen on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;HTTPRoute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;How to route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Backend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Target hostname and port. An Envoy Gateway specific CRD&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Think of it as the standard Kubernetes API group that succeeded Ingress.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Architecture
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3-1. Control plane and data plane
&lt;/h3&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F03-control-data-plane.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F03-control-data-plane.png" alt="Control plane and data plane" width="800" height="1294"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;None of the AI-specific logic lives inside Envoy itself.&lt;/strong&gt; All of it is pushed out into an external process called ExtProc.&lt;/p&gt;

&lt;p&gt;Since that term just appeared for the first time, here is what it means. ExtProc is a stock Envoy HTTP filter that streams request and response headers and bodies to an external process over gRPC, and then &lt;strong&gt;applies whatever that external process rewrote back into Envoy&lt;/strong&gt;. Envoy AI Gateway implements its side in Go. That means no C++ to write, and no coupling to Envoy's own release cycle.&lt;/p&gt;

&lt;h3&gt;
  
  
  3-2. Why ExtProc is wired in twice
&lt;/h3&gt;

&lt;p&gt;ExtProc sits in two places: &lt;strong&gt;in front of the router filter&lt;/strong&gt; and &lt;strong&gt;at the upstream filter position&lt;/strong&gt;. This is the design decision that pays off the most, so it is worth explaining before you see the effect.&lt;/p&gt;

&lt;p&gt;Later in this article you will configure "if the primary provider fails, fall back to a different one." In Envoy, that retry happens &lt;strong&gt;after&lt;/strong&gt; the router filter, at the upstream level. Here is the problem: if the primary is OpenAI and the fallback is Bedrock, then &lt;strong&gt;the moment you fail over, both the body translation and the auth header have to change&lt;/strong&gt;. Translating once, before the router, would be too early.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F04-extproc-two-stages.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F04-extproc-two-stages.png" alt="ExtProc at the router stage and the upstream stage" width="800" height="1142"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Splitting it in two exists to preserve an obvious ordering rule: translate only after the destination is known.&lt;/p&gt;

&lt;h3&gt;
  
  
  3-3. The life of a request
&lt;/h3&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F05-request-lifecycle.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F05-request-lifecycle.png" alt="The life of a request" width="800" height="1061"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two things to take away.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;x-ai-eg-model&lt;/code&gt; is &lt;strong&gt;not a header the client sets&lt;/strong&gt;. The gateway extracts it from the &lt;code&gt;model&lt;/code&gt; field in the body and attaches it itself. Your routing rules match on that header.&lt;/p&gt;

&lt;p&gt;Second, &lt;em&gt;dynamic metadata&lt;/em&gt;. This is a named scratch area Envoy keeps per request, used to pass values between filters. ExtProc writes token counts there, and the rate limiter downstream reads them. Section 10 puts it to work.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The CRDs worth memorizing
&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F06-crd-relationships.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F06-crd-relationships.png" alt="How the CRDs reference each other" width="800" height="502"&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;Resource&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AIGatewayRoute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The unified API as clients see it. Picks a backend by header match. &lt;strong&gt;Generates an &lt;code&gt;HTTPRoute&lt;/code&gt; with the same name&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;AIServiceBackend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;One upstream. &lt;code&gt;schema&lt;/code&gt; declares what format that upstream speaks, which decides the translation target&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;BackendSecurityPolicy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Upstream auth. API keys, plus short-lived tokens fetched from AWS STS, Entra ID, or GCP STS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Backend&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Hostname and port. An Envoy Gateway resource, not AI-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;MCPRoute&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Aggregates MCP servers. Section 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;QuotaPolicy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Cumulative token budgets. Section 10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GatewayConfig&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Per-Gateway ExtProc settings&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One thing trips people up here. In v1.0, &lt;code&gt;AIGatewayRoute&lt;/code&gt; has &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;schema&lt;/code&gt; field. The input schema is &lt;strong&gt;determined by the request path&lt;/strong&gt;: &lt;code&gt;/v1/chat/completions&lt;/code&gt; means OpenAI format, &lt;code&gt;/anthropic/v1/messages&lt;/code&gt; means Anthropic format. The only thing you declare is the egress side, &lt;code&gt;AIServiceBackend.schema&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;At v1.0 GA, &lt;code&gt;AIGatewayRoute&lt;/code&gt;, &lt;code&gt;AIServiceBackend&lt;/code&gt;, &lt;code&gt;BackendSecurityPolicy&lt;/code&gt;, &lt;code&gt;GatewayConfig&lt;/code&gt;, and &lt;code&gt;MCPRoute&lt;/code&gt; all became stable at &lt;code&gt;v1beta1&lt;/code&gt;. The maintainers state plainly that they will not break these APIs short of a critical security issue. &lt;code&gt;QuotaPolicy&lt;/code&gt; is still &lt;code&gt;v1alpha1&lt;/code&gt; and sits outside that promise.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Hands-on: run it without Kubernetes
&lt;/h2&gt;

&lt;p&gt;Envoy AI Gateway ships a CLI called &lt;code&gt;aigw&lt;/code&gt; that stands up a gateway locally &lt;strong&gt;with neither Kubernetes nor Docker&lt;/strong&gt;, eating exactly the same CRD YAML. It is good for learning and for validating config before you ship it. Linux and macOS only.&lt;/p&gt;

&lt;h3&gt;
  
  
  5-1. Install aigw
&lt;/h3&gt;

&lt;p&gt;If you have Go, this is the fastest path.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;go &lt;span class="nb"&gt;install &lt;/span&gt;github.com/envoyproxy/ai-gateway/cmd/aigw@v1.0.0
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;PATH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$PATH&lt;/span&gt;:&lt;span class="si"&gt;$(&lt;/span&gt;go &lt;span class="nb"&gt;env &lt;/span&gt;GOPATH&lt;span class="si"&gt;)&lt;/span&gt;/bin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker works too.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 1975:1975 &lt;span class="nt"&gt;-e&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="nv"&gt;$OPENAI_API_KEY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  envoyproxy/ai-gateway-cli run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prebuilt binaries are on &lt;a href="https://github.com/envoyproxy/ai-gateway/releases" rel="noopener noreferrer"&gt;GitHub Releases&lt;/a&gt;. The whole subcommand surface is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  version                Show version.
  run [&amp;lt;path&amp;gt;]           Run the AI Gateway locally for given configuration.
  healthcheck            Docker HEALTHCHECK command.
  download-envoy         Download Envoy binary for the Envoy Gateway default version.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On first start, &lt;code&gt;aigw run&lt;/code&gt; fetches the Envoy binary for you. On my machine that put Envoy 1.38.1 under &lt;code&gt;~/.local/share/aigw/&lt;/code&gt;, about 97MB. Later runs reuse it.&lt;/p&gt;

&lt;p&gt;One gotcha: if you installed via &lt;code&gt;go install&lt;/code&gt;, &lt;code&gt;aigw version&lt;/code&gt; prints &lt;code&gt;dev&lt;/code&gt; because the build info never got stamped in. Use a release binary or the Docker image if you need to confirm the version.&lt;/p&gt;

&lt;h3&gt;
  
  
  5-2. Stand up a mock LLM to talk to
&lt;/h3&gt;

&lt;p&gt;Burning real OpenAI credit for this would be wasteful, so here is a server that returns an OpenAI-compatible response and nothing else, written against the Python standard library. Zero dependencies.&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="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Minimal OpenAI-compatible mock upstream, for testing aigw locally.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;upstream &amp;lt;- %s %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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&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="n"&gt;flush&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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;content-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;body&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sa"&gt;b&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="c1"&gt;# observe what the gateway injected on the way in
&lt;/span&gt;        &lt;span class="nf"&gt;print&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=%r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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;authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;flush&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="nf"&gt;print&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=%r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;body&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;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;flush&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&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;chatcmpl-mock&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;object&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;chat.completion&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;created&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;time&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&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;model&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;mock-gpt&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;choices&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;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;finish_reason&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;stop&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;message&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;role&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;assistant&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;content&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;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ll be back.&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;usage&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;prompt_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;completion_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;raw&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&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;raw&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11434&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save it as &lt;code&gt;mockllm.py&lt;/code&gt; and start it. Port 11434 matches Ollama's default, so if you already run Ollama you can point at that instead of the mock.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 mockllm.py &amp;amp;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5-3. Start it with no config file
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;aigw run&lt;/code&gt; generates its own config from environment variables. If any of &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;, &lt;code&gt;AZURE_OPENAI_API_KEY&lt;/code&gt;, or &lt;code&gt;ANTHROPIC_API_KEY&lt;/code&gt; is set, no config file is needed. Override &lt;code&gt;OPENAI_BASE_URL&lt;/code&gt; to aim it at the mock or at Ollama.&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;OPENAI_BASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://localhost:11434/v1 &lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;unused aigw run
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AI Gateway External Processor is ready
Envoy AI Gateway listening on http://localhost:1975 (admin http://localhost:52816) after 6.3s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First start took 6.3 seconds including the Envoy download. Later starts took 1.4 seconds.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;admin&lt;/code&gt; port in that log line is &lt;strong&gt;Envoy's own admin port&lt;/strong&gt;, and it changes on every start. The aigw admin endpoint you will use shortly is a different thing, fixed at &lt;strong&gt;1064&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  5-4. Send a request
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-XPOST&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost:1975/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"mock-gpt","messages":[{"role":"user","content":"Say this is a test!"}]}'&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;"chatcmpl-mock"&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;"chat.completion"&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;"mock-gpt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"choices"&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;"index"&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;"finish_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"message"&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;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'll be back."&lt;/span&gt;&lt;span class="p"&gt;}}],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"usage"&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;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"total_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&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;That worked. The interesting part is on the mock side.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;authorization='Bearer unused'
model='mock-gpt'
upstream &amp;lt;- POST /v1/chat/completions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The curl command sent no &lt;code&gt;Authorization&lt;/code&gt; header at all, yet the upstream received &lt;code&gt;Bearer unused&lt;/code&gt;. &lt;strong&gt;The gateway injected &lt;code&gt;OPENAI_API_KEY&lt;/code&gt;.&lt;/strong&gt; That is "get keys out of the application" made concrete, and on Kubernetes &lt;code&gt;BackendSecurityPolicy&lt;/code&gt; fills the same role.&lt;/p&gt;

&lt;h3&gt;
  
  
  5-5. Tokens are already being counted
&lt;/h3&gt;

&lt;p&gt;The aigw admin port at 1064 serves Prometheus-format metrics.&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;-s&lt;/span&gt; http://localhost:1064/health
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:1064/metrics | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-E&lt;/span&gt; &lt;span class="s1"&gt;'gen_ai.*(_sum|_count)\{'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OK
gen_ai_client_token_usage_sum{gen_ai_operation_name="chat",gen_ai_provider_name="openai",gen_ai_request_model="mock-gpt",gen_ai_token_type="input"} 11
gen_ai_client_token_usage_sum{...,gen_ai_token_type="output"} 5
gen_ai_server_request_duration_seconds_sum{...} 0.010359625
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;prompt_tokens: 11&lt;/code&gt; and &lt;code&gt;completion_tokens: 5&lt;/code&gt; the mock returned landed straight in the metrics. Names and labels follow the &lt;a href="https://opentelemetry.io/docs/specs/semconv/attributes-registry/gen-ai/" rel="noopener noreferrer"&gt;OpenTelemetry GenAI semantic conventions&lt;/a&gt;, so dashboards can stay vendor neutral.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;gen_ai_client_token_usage&lt;/code&gt; is exported as a &lt;strong&gt;histogram&lt;/strong&gt;, not a counter. Use &lt;code&gt;_sum&lt;/code&gt; for total tokens and &lt;code&gt;_count&lt;/code&gt; for request counts.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;What it tells you&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gen_ai.client.token.usage&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Token consumption. &lt;code&gt;gen_ai.token.type&lt;/code&gt; splits input from output&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gen_ai.server.request.duration&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;End-to-end request latency&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gen_ai.server.time_to_first_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Time to the first token. Perceived responsiveness&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;gen_ai.server.time_per_output_token&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inter-token latency. Generation speed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For LLMs, how fast something feels is usually decided by time to first token rather than total latency, so getting that out of the box matters.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Hands-on: watch schema translation happen
&lt;/h2&gt;

&lt;p&gt;Section 1 claimed that restructuring JSON is the least glamorous and most valuable thing here. Time to see it.&lt;/p&gt;

&lt;p&gt;The setup: &lt;strong&gt;stand up a mock that expects requests in Bedrock Converse format&lt;/strong&gt;, then &lt;strong&gt;send it OpenAI format from the client&lt;/strong&gt;. Print the raw body the upstream received and the gateway's work is right there on screen.&lt;/p&gt;

&lt;h3&gt;
  
  
  6-1. A mock that speaks Bedrock
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;AWS Bedrock Converse format mock upstream.
   Prints whatever the gateway translated the request into.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;pass&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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;content-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=== path the upstream received ===&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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="n"&gt;flush&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;=== body the upstream received ===&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flush&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="nf"&gt;print&lt;/span&gt;&lt;span class="p"&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;dumps&lt;/span&gt;&lt;span class="p"&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;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sa"&gt;b&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;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;flush&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="c1"&gt;# respond in Bedrock Converse format
&lt;/span&gt;        &lt;span class="n"&gt;resp&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;output&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;message&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;role&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;assistant&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;content&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;text&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;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;ll be back.&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;stopReason&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;end_turn&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;usage&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;inputTokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;outputTokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totalTokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;body&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;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&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;body&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11600&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  6-2. Set the egress schema to AWSBedrock
&lt;/h3&gt;

&lt;p&gt;The config is short. Set &lt;code&gt;schema.name&lt;/code&gt; on the &lt;code&gt;AIServiceBackend&lt;/code&gt; to &lt;code&gt;AWSBedrock&lt;/code&gt; and that is it.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigateway.envoyproxy.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AIServiceBackend&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bedrock-mock&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AWSBedrock&lt;/span&gt;
  &lt;span class="na"&gt;backendRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bedrock-mock&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bedrock-mock&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;fqdn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;11600&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the same &lt;code&gt;GatewayClass&lt;/code&gt;, &lt;code&gt;Gateway&lt;/code&gt;, and &lt;code&gt;AIGatewayRoute&lt;/code&gt; used in section 7, then start it with &lt;code&gt;aigw run translate.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  6-3. Send OpenAI format
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-XPOST&lt;/span&gt; http://localhost:1975/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"claude-mock","max_tokens":100,
       "messages":[{"role":"system","content":"You are terse."},
                   {"role":"user","content":"hi"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is what the upstream received.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== path the upstream received ===
/model/claude-mock/converse
=== body the upstream received ===
{
  "inferenceConfig": {
    "maxTokens": 100
  },
  "messages": [
    {
      "content": [
        {
          "text": "hi"
        }
      ],
      "role": "user"
    }
  ],
  "system": [
    {
      "text": "You are terse."
    }
  ]
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three separate things happened.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F07-schema-translation.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F07-schema-translation.png" alt="OpenAI format translated into Bedrock Converse format" width="799" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The third one, peeling the system prompt out of the &lt;code&gt;messages&lt;/code&gt; array and relocating it into a standalone &lt;code&gt;system&lt;/code&gt; array, is exactly the kind of thing you cannot express in reverse proxy configuration.&lt;/p&gt;

&lt;h3&gt;
  
  
  6-4. The return trip gets translated too
&lt;/h3&gt;

&lt;p&gt;The mock replies in Bedrock format. This is what reached the client.&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;"choices"&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;"finish_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
            &lt;/span&gt;&lt;span class="nl"&gt;"index"&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;"message"&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;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'll be back."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&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;"claude-mock"&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;"chat.completion"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"usage"&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;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"total_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&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;output.message.content[0].text&lt;/code&gt; became &lt;code&gt;choices[0].message.content&lt;/code&gt;, and &lt;code&gt;usage.inputTokens&lt;/code&gt; became &lt;code&gt;usage.prompt_tokens&lt;/code&gt;. An OpenAI SDK will consume this unmodified.&lt;/p&gt;

&lt;h3&gt;
  
  
  6-5. Where I got stuck: &lt;code&gt;Anthropic&lt;/code&gt; is not a valid translation target
&lt;/h3&gt;

&lt;p&gt;My first attempt wrote the mock in Anthropic Messages format and set &lt;code&gt;schema.name: Anthropic&lt;/code&gt;. It returned 500, with this in the log.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;level=ERROR msg="error processing request message"
  error="... failed to create translator for backend ...: unsupported API schema: backend={Anthropic  v1}"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Digging into it, the cause was not what I assumed. It is not that the translation is impossible. &lt;strong&gt;That combination simply is not registered in a switch.&lt;/strong&gt; The error comes from here, in &lt;code&gt;internal/endpointspec/endpointspec.go&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// ChatCompletionsEndpointSpec.GetTranslator&lt;/span&gt;
&lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;filterapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APISchemaOpenAI&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;        &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;filterapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APISchemaAWSBedrock&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;    &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;filterapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APISchemaAWSAnthropic&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;filterapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APISchemaAzureOpenAI&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;filterapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APISchemaGCPVertexAI&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;   &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;filterapi&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APISchemaGCPAnthropic&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;  &lt;span class="o"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unsupported API schema: backend=%s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&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;There is no &lt;code&gt;case&lt;/code&gt; for &lt;code&gt;APISchemaAnthropic&lt;/code&gt;. That is the whole story.&lt;/p&gt;

&lt;p&gt;The body translation from OpenAI format to Anthropic Messages format &lt;strong&gt;exists and works today&lt;/strong&gt;. Both &lt;code&gt;openai_awsanthropic.go&lt;/code&gt; and &lt;code&gt;openai_gcpanthropic.go&lt;/code&gt; call the same &lt;code&gt;buildAnthropicParams(openAIReq, ...)&lt;/code&gt;, and what that returns is &lt;code&gt;*anthropic.MessageNewParams&lt;/code&gt;, the struct from Anthropic's official Go SDK. What is missing is the branch for "when the destination is api.anthropic.com," which would differ only by setting the path to &lt;code&gt;/v1/messages&lt;/code&gt; and skipping the &lt;code&gt;anthropic_version&lt;/code&gt; injection.&lt;/p&gt;

&lt;p&gt;By contrast, the switch for requests entering through &lt;code&gt;/anthropic/v1/messages&lt;/code&gt; has all five filled in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// MessagesEndpointSpec.GetTranslator&lt;/span&gt;
&lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="n"&gt;APISchemaGCPAnthropic&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;APISchemaAWSAnthropic&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;APISchemaAnthropic&lt;/span&gt;
   &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;APISchemaOpenAI&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;APISchemaAWSBedrock&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can accept Anthropic format and emit to an OpenAI backend. Only the reverse direction has a hole in it.&lt;/p&gt;

&lt;p&gt;In practice this is what it means:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Where your Claude lives&lt;/th&gt;
&lt;th&gt;Usable from an OpenAI-format client?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;On Bedrock (&lt;code&gt;AWSAnthropic&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On Vertex AI (&lt;code&gt;GCPAnthropic&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Direct contract with Anthropic (&lt;code&gt;Anthropic&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No.&lt;/strong&gt; You have to receive on &lt;code&gt;/anthropic/v1/messages&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The most straightforward setup is the least convenient one. This is a v1.0.0 observation and a later version may well close the gap.&lt;/p&gt;

&lt;p&gt;Also worth clearing up: &lt;code&gt;AWSAnthropic&lt;/code&gt; and &lt;code&gt;Anthropic&lt;/code&gt; are not two routes to the same place. &lt;strong&gt;They are the same model bought from different vendors.&lt;/strong&gt; api.anthropic.com does not accept IAM credentials, and Bedrock does not accept an Anthropic API key. Which one you write is not a preference, it is dictated by where your Claude actually lives.&lt;/p&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;&lt;code&gt;Anthropic&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;AWSAnthropic&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;AWSBedrock&lt;/code&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Destination&lt;/td&gt;
&lt;td&gt;api.anthropic.com&lt;/td&gt;
&lt;td&gt;Bedrock&lt;/td&gt;
&lt;td&gt;Bedrock&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Path&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/v1/messages&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/model/&amp;lt;id&amp;gt;/invoke&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;/model/&amp;lt;id&amp;gt;/converse&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Body&lt;/td&gt;
&lt;td&gt;Anthropic native&lt;/td&gt;
&lt;td&gt;Anthropic native plus &lt;code&gt;anthropic_version&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Converse's shared format&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AnthropicAPIKey&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AWSCredentials&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AWSCredentials&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;AWSAnthropic&lt;/code&gt; and &lt;code&gt;AWSBedrock&lt;/code&gt; both target Bedrock, but the former uses InvokeModel and drops the Anthropic body into a Bedrock envelope untouched, while the latter repacks everything into Converse, the format shared across all Bedrock models. Pick Converse if you want to swap between Claude and Llama with one request shape. Pick InvokeModel if you want Anthropic-specific structures to pass through intact.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;schema&lt;/code&gt; is decided by three things, not one: the shape of the upstream API, &lt;strong&gt;which vendor you bought from&lt;/strong&gt;, and &lt;strong&gt;whether that pairing with your ingress path is implemented&lt;/strong&gt;. These are the eight values defined at v1.0.0.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenAI  AzureOpenAI  AWSBedrock  AWSAnthropic
Anthropic  GCPVertexAI  GCPAnthropic  Cohere
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  7. Hands-on: fallback and model name virtualization
&lt;/h2&gt;

&lt;p&gt;What you actually want in production is "fail over to another provider when the primary dies" and "keep the model name fixed as far as the application is concerned." Here is both.&lt;/p&gt;

&lt;h3&gt;
  
  
  7-1. Stand up an upstream that fails
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;#!/usr/bin/env python3
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;An upstream that always returns 503, for testing fallback.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;http.server&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;HTTPServer&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseHTTPRequestHandler&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;log_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary(broken) &amp;lt;- %s %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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;command&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&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="n"&gt;flush&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;do_POST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;headers&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;content-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;b&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="s"&gt;error&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="s"&gt;message&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="s"&gt;upstream is down&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="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&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;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send_header&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;str&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;body&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end_headers&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wfile&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&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="nc"&gt;HTTPServer&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;127.0.0.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;11500&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;serve_forever&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Port 11500 is the broken primary. Port 11434 from section 5 is the healthy fallback.&lt;/p&gt;

&lt;h3&gt;
  
  
  7-2. Write the config
&lt;/h3&gt;

&lt;p&gt;Save this as &lt;code&gt;aigw-config.yaml&lt;/code&gt;. It is exactly the same CRD YAML you would use on Kubernetes, and you could &lt;code&gt;kubectl apply&lt;/code&gt; it as is.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;GatewayClass&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigw-run&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;controllerName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/gatewayclass-controller&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigw-run&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;gatewayClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigw-run&lt;/span&gt;
  &lt;span class="na"&gt;listeners&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
      &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1975&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigateway.envoyproxy.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AIGatewayRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigw-run&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigw-run&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# the client only ever knows the invented model name "team-chat"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&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;Exact&lt;/span&gt;
              &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;x-ai-eg-model&lt;/span&gt;
              &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;team-chat&lt;/span&gt;
      &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;primary&lt;/span&gt;
          &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
          &lt;span class="na"&gt;modelNameOverride&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;broken-model&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secondary&lt;/span&gt;
          &lt;span class="na"&gt;priority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
          &lt;span class="na"&gt;modelNameOverride&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mock-gpt&lt;/span&gt;
  &lt;span class="na"&gt;llmRequestCosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadataKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_input_token&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;InputToken&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadataKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_output_token&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;OutputToken&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigateway.envoyproxy.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AIServiceBackend&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;primary&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenAI&lt;/span&gt;
  &lt;span class="na"&gt;backendRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;primary&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;primary&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;fqdn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;11500&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigateway.envoyproxy.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AIServiceBackend&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secondary&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;OpenAI&lt;/span&gt;
  &lt;span class="na"&gt;backendRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secondary&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
    &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;secondary&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;endpoints&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;fqdn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;
        &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;11434&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackendTrafficPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fallback&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# an HTTPRoute is generated with the same name as the AIGatewayRoute, so aim at that&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRoute&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigw-run&lt;/span&gt;
  &lt;span class="na"&gt;retry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;numRetries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2&lt;/span&gt;
    &lt;span class="c1"&gt;# only one attempt per priority, so it drops to the next one immediately&lt;/span&gt;
    &lt;span class="na"&gt;numAttemptsPerPriority&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;perRetry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;backOff&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;baseInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;100ms&lt;/span&gt;
        &lt;span class="na"&gt;maxInterval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;30s&lt;/span&gt;
    &lt;span class="na"&gt;retryOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;httpStatusCodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;503&lt;/span&gt;
      &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;connect-failure&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;retriable-status-codes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three parts are worth reading closely.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;priority&lt;/code&gt; is lowest-wins, so &lt;code&gt;0&lt;/code&gt; is the intended target and anything from &lt;code&gt;1&lt;/code&gt; up is a fallback.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;modelNameOverride&lt;/code&gt; rewrites the &lt;code&gt;team-chat&lt;/code&gt; the client sent into a different name right before it goes upstream. The same Claude Sonnet is called &lt;code&gt;anthropic.claude-sonnet-4-20250514-v1:0&lt;/code&gt; on Bedrock and &lt;code&gt;claude-sonnet-4@20250514&lt;/code&gt; on Vertex AI. This is the feature that keeps that difference from leaking into your application.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BackendTrafficPolicy&lt;/code&gt; targets not the &lt;code&gt;AIGatewayRoute&lt;/code&gt; but the &lt;strong&gt;&lt;code&gt;HTTPRoute&lt;/code&gt; generated from it&lt;/strong&gt;, which carries the same name. Section 9 shows that generated object for real.&lt;/p&gt;

&lt;h3&gt;
  
  
  7-3. Run it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aigw run aigw-config.yaml
&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\nHTTP %{http_code} in %{time_total}s\n'&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: application/json"&lt;/span&gt; &lt;span class="nt"&gt;-XPOST&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost:1975/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"team-chat","messages":[{"role":"user","content":"hi"}]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{"id": "chatcmpl-mock", ..., "message": {"role": "assistant", "content": "I'll be back."}}
HTTP 200 in 0.113045s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client got a 200. The logs on both upstreams show what happened behind it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== primary(broken) ===
primary(broken) &amp;lt;- POST /v1/chat/completions

=== secondary ===
model='mock-gpt'
upstream &amp;lt;- POST /v1/chat/completions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One attempt against the primary, a 503, then a switch to the secondary. And the model name the secondary received is &lt;strong&gt;&lt;code&gt;mock-gpt&lt;/code&gt;&lt;/strong&gt;, not &lt;code&gt;team-chat&lt;/code&gt;, so &lt;code&gt;modelNameOverride&lt;/code&gt; is doing its job. Of the 113ms total, 100ms is the &lt;code&gt;baseInterval&lt;/code&gt; backoff we configured.&lt;/p&gt;

&lt;h3&gt;
  
  
  7-4. The metrics keep a record of the virtualization
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gen_ai_client_token_usage_sum{gen_ai_original_model="team-chat",gen_ai_request_model="mock-gpt",gen_ai_response_model="mock-gpt",gen_ai_token_type="input"} 11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;gen_ai_original_model&lt;/code&gt; and &lt;code&gt;gen_ai_request_model&lt;/code&gt; sit side by side as separate labels. &lt;strong&gt;You can trace both what the application asked for and which model actually served it.&lt;/strong&gt; Without that, investigating the blast radius of a provider migration is hopeless.&lt;/p&gt;

&lt;h3&gt;
  
  
  7-5. Send a model that is not in any route
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s1"&gt;'\nHTTP %{http_code}\n'&lt;/span&gt; &lt;span class="nt"&gt;-XPOST&lt;/span&gt; http://localhost:1975/v1/chat/completions &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"nope","messages":[]}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No matching route found. It is likely because the model specified in your request is not configured in the Gateway.
HTTP 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Models are allowlisted.&lt;/strong&gt; Anything not written into an &lt;code&gt;AIGatewayRoute&lt;/code&gt; does not get through. That prevents someone accidentally hammering an expensive model, at the cost of needing a config change to adopt a new one. Build that into your operational process.&lt;/p&gt;

&lt;h3&gt;
  
  
  7-6. Where I got stuck: forgetting &lt;code&gt;retriable-status-codes&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;My first &lt;code&gt;retryOn&lt;/code&gt; looked like this, and fallback silently did nothing.&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;retryOn&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;httpStatusCodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="m"&gt;503&lt;/span&gt;
      &lt;span class="na"&gt;triggers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;connect-failure&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;reset&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It says &lt;code&gt;httpStatusCodes: [503]&lt;/code&gt; right there, and yet a 503 does not trigger a retry. This is Envoy behavior: &lt;strong&gt;unless &lt;code&gt;triggers&lt;/code&gt; contains &lt;code&gt;retriable-status-codes&lt;/code&gt;, the &lt;code&gt;httpStatusCodes&lt;/code&gt; list is never evaluated at all&lt;/strong&gt;. &lt;code&gt;httpStatusCodes&lt;/code&gt; defines &lt;em&gt;which codes count as retriable&lt;/em&gt;, and "retry on retriable codes" is a separate trigger you have to switch on.&lt;/p&gt;

&lt;p&gt;This is Envoy in general rather than anything specific to Envoy AI Gateway, but it is the easiest trap to fall into when wiring up fallback.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Hands-on: use it as an MCP gateway
&lt;/h2&gt;

&lt;p&gt;Since v0.4, Envoy AI Gateway also works as a gateway for the &lt;a href="https://modelcontextprotocol.io/" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;. It looks like a separate topic from LLM traffic, but the shape of the problem is identical. Connect an agent to five MCP servers and you have five sets of credentials scattered around and no record of which tools got called.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F08-mcp-aggregation.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F08-mcp-aggregation.png" alt="MCP servers aggregated behind one endpoint" width="800" height="636"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Name collisions between tools are avoided with &lt;strong&gt;a prefix derived from the backend name&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  8-1. Try it against a public MCP server that needs no auth
&lt;/h3&gt;

&lt;p&gt;You can pass config inline with &lt;code&gt;--mcp-json&lt;/code&gt;. The format is the same &lt;code&gt;mcpServers&lt;/code&gt; shape Claude Desktop and VS Code use.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aigw run &lt;span class="nt"&gt;--mcp-json&lt;/span&gt; &lt;span class="s1"&gt;'{"mcpServers":{"deepwiki":{"type":"http","url":"https://mcp.deepwiki.com/mcp"}}}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull the tool list with the MCP Inspector in CLI mode.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;--yes&lt;/span&gt; @modelcontextprotocol/inspector@0.16.8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cli&lt;/span&gt; http://localhost:1975/mcp &lt;span class="nt"&gt;--method&lt;/span&gt; tools/list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"name": "deepwiki__ask_question",
"name": "deepwiki__read_wiki_contents",
"name": "deepwiki__read_wiki_structure",
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Upstream, DeepWiki publishes these as &lt;code&gt;ask_question&lt;/code&gt; and so on. Through the gateway they come back with &lt;strong&gt;&lt;code&gt;deepwiki__&lt;/code&gt; prepended&lt;/strong&gt;. Add more backends and the names still will not collide.&lt;/p&gt;

&lt;p&gt;Now call one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx &lt;span class="nt"&gt;--yes&lt;/span&gt; @modelcontextprotocol/inspector@0.16.8 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cli&lt;/span&gt; http://localhost:1975/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--method&lt;/span&gt; tools/call &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tool-name&lt;/span&gt; deepwiki__read_wiki_structure &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tool-arg&lt;/span&gt; &lt;span class="nv"&gt;repoName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;envoyproxy/ai-gateway
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Available pages for envoyproxy/ai-gateway:
- 1 Overview
  - 1.1 Key Concepts
  - 1.2 Architecture Overview
- 2 System Architecture
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The prefix got stripped and the call landed on the right backend.&lt;/p&gt;

&lt;h3&gt;
  
  
  8-2. Narrow the tool list
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--mcp-config&lt;/code&gt; reads the same thing from a file. Headers support environment variable interpolation with &lt;code&gt;${VAR}&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;"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;"context7"&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.context7.com/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="nl"&gt;"github"&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://api.githubcopilot.com/mcp/readonly"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"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;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer ${GITHUB_ACCESS_TOKEN}"&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;"includeTools"&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;"issue_read"&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_issues"&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;includeTools&lt;/code&gt; is the useful one. &lt;strong&gt;You get to decide, at the gateway, which tools an agent can even see.&lt;/strong&gt; The GitHub MCP server has write operations too, but here only two read operations are exposed. As a defense against prompt injection, simply not showing an agent the dangerous tools has an unusually good ratio of effect to implementation cost.&lt;/p&gt;

&lt;p&gt;On Kubernetes the same thing goes in an &lt;code&gt;MCPRoute&lt;/code&gt;.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigateway.envoyproxy.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MCPRoute&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mcp-route&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;parentRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigw-run&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
  &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mcp"&lt;/span&gt;
  &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io&lt;/span&gt;
      &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mcp/x/issues/readonly"&lt;/span&gt;
      &lt;span class="na"&gt;toolSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;includeRegex&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;.*issues?.*&lt;/span&gt;
      &lt;span class="na"&gt;securityPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;secretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github-token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;toolSelector&lt;/code&gt; takes exactly one of &lt;code&gt;include&lt;/code&gt; or &lt;code&gt;includeRegex&lt;/code&gt;. You cannot set both. Omit it entirely and every tool is exposed.&lt;/p&gt;

&lt;p&gt;Going further, &lt;code&gt;securityPolicy.oauth&lt;/code&gt; terminates the MCP spec's OAuth flow at the gateway and lets you evaluate JWT claims and scopes with CEL expressions to authorize individual tools.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Move it to Kubernetes
&lt;/h2&gt;

&lt;p&gt;Once it makes sense locally, take it to a cluster. The YAML from section 7 carries over almost unchanged.&lt;/p&gt;

&lt;h3&gt;
  
  
  9-1. Prerequisites
&lt;/h3&gt;

&lt;p&gt;You need &lt;strong&gt;Kubernetes 1.32 or newer&lt;/strong&gt;. That is the requirement most likely to disqualify an existing cluster. Beyond that, kubectl, helm, and curl.&lt;/p&gt;

&lt;p&gt;Locally, kind is fine.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; aigw
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Install Envoy Gateway with the AI Gateway values applied.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;-i&lt;/span&gt; eg oci://docker.io/envoyproxy/gateway-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.8.1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; envoy-gateway-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/envoyproxy/ai-gateway/main/manifests/envoy-gateway-values.yaml

kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5m &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system &lt;span class="se"&gt;\&lt;/span&gt;
  deployment/envoy-gateway &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If Envoy Gateway is already installed, the extension server will not be enabled without these values. The project recommends reinstalling from a clean slate.&lt;/p&gt;

&lt;h3&gt;
  
  
  9-2. AI Gateway itself
&lt;/h3&gt;

&lt;p&gt;The CRDs and the controller ship as separate charts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;-i&lt;/span&gt; aieg-crd oci://docker.io/envoyproxy/ai-gateway-crds-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.0.0 &lt;span class="nt"&gt;--namespace&lt;/span&gt; envoy-ai-gateway-system &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;

helm upgrade &lt;span class="nt"&gt;-i&lt;/span&gt; aieg oci://docker.io/envoyproxy/ai-gateway-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.0.0 &lt;span class="nt"&gt;--namespace&lt;/span&gt; envoy-ai-gateway-system &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;

kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;5m &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-ai-gateway-system &lt;span class="se"&gt;\&lt;/span&gt;
  deployment/ai-gateway-controller &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Available
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Splitting the CRDs into their own chart started in v1.0, so upgrading from anything older requires a transfer of ownership.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;helm upgrade &lt;span class="nt"&gt;-i&lt;/span&gt; aieg-crd oci://docker.io/envoyproxy/ai-gateway-crds-helm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--version&lt;/span&gt; v1.0.0 &lt;span class="nt"&gt;--namespace&lt;/span&gt; envoy-ai-gateway-system &lt;span class="nt"&gt;--take-ownership&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a kind node with 2 vCPU and 4GB, everything up to this point took a little over five minutes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAMESPACE                 NAME                                     READY   STATUS
envoy-ai-gateway-system   ai-gateway-controller-7d76dd5b85-vqgsf   1/1     Running
envoy-gateway-system      envoy-gateway-f97c95b-5s2q9              1/1     Running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  9-3. Get traffic flowing
&lt;/h3&gt;

&lt;p&gt;The official sample works as is.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/envoyproxy/ai-gateway/main/examples/basic/basic.yaml

kubectl &lt;span class="nb"&gt;wait &lt;/span&gt;pods &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;4m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-l&lt;/span&gt; gateway.envoyproxy.io/owning-gateway-name&lt;span class="o"&gt;=&lt;/span&gt;envoy-ai-gateway-basic &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Ready
&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;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENVOY_SERVICE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get svc &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--selector&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gateway.envoyproxy.io/owning-gateway-namespace&lt;span class="o"&gt;=&lt;/span&gt;default,gateway.envoyproxy.io/owning-gateway-name&lt;span class="o"&gt;=&lt;/span&gt;envoy-ai-gateway-basic &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.items[0].metadata.name}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

kubectl port-forward &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system svc/&lt;span class="nv"&gt;$ENVOY_SERVICE&lt;/span&gt; 8080:80
&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;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"model":"some-cool-self-hosted-model","messages":[{"role":"system","content":"Hi."}]}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  http://localhost:8080/v1/chat/completions
&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;"choices"&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;"index"&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="nl"&gt;"message"&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;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"assistant"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"I'm king of the world!"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="nl"&gt;"finish_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stop"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="nl"&gt;"usage"&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;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"total_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;300&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;Traffic flows. As a side note, the official docs show the expected response as &lt;code&gt;"I'll be back."&lt;/code&gt;, but the v1.0.0 test upstream image actually returns &lt;code&gt;"I'm king of the world!"&lt;/code&gt;. It is a hardcoded string either way, so it does not matter.&lt;/p&gt;

&lt;h3&gt;
  
  
  9-4. Look at what got generated
&lt;/h3&gt;

&lt;p&gt;This is the part most worth inspecting on the Kubernetes side. Applying a single &lt;code&gt;AIGatewayRoute&lt;/code&gt; grew several resources behind your back.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get httproute,httproutefilter &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAMESPACE   NAME                                                    AGE
default     httproute.../envoy-ai-gateway-basic                     5m
default     httproutefilter.../ai-eg-host-rewrite-envoy-ai-gateway-basic               5m
default     httproutefilter.../ai-eg-route-not-found-response-envoy-ai-gateway-basic   5m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is &lt;strong&gt;an &lt;code&gt;HTTPRoute&lt;/code&gt; with the same name as the &lt;code&gt;AIGatewayRoute&lt;/code&gt;&lt;/strong&gt;. That is what section 7 pointed &lt;code&gt;BackendTrafficPolicy&lt;/code&gt; at. The contents tell you more.&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;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;backendRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-basic-testupstream&lt;/span&gt;
      &lt;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
    &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;extensionRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRouteFilter&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-eg-host-rewrite-envoy-ai-gateway-basic&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;ExtensionRef&lt;/span&gt;
    &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;x-ai-eg-model&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;Exact&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;some-cool-self-hosted-model&lt;/span&gt;
      &lt;span class="na"&gt;path&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;PathPrefix&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;timeouts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;60s&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;extensionRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HTTPRouteFilter&lt;/span&gt;
        &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ai-eg-route-not-found-response-envoy-ai-gateway-basic&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;ExtensionRef&lt;/span&gt;
    &lt;span class="na"&gt;matches&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&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;PathPrefix&lt;/span&gt;
        &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;route-not-found&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below the rule I wrote, &lt;strong&gt;a catch-all rule named &lt;code&gt;route-not-found&lt;/code&gt; has been appended automatically&lt;/strong&gt;. That is the origin of the 404 message from section 7-5.&lt;/p&gt;

&lt;p&gt;Because of that insertion, a single &lt;code&gt;AIGatewayRoute&lt;/code&gt; can hold at most &lt;strong&gt;15 rules&lt;/strong&gt;. Gateway API caps &lt;code&gt;HTTPRoute.spec.rules&lt;/code&gt; at 16, and one slot is reserved for the catch-all. Need more than that and you split the &lt;code&gt;AIGatewayRoute&lt;/code&gt; and attach both to the same Gateway.&lt;/p&gt;

&lt;p&gt;You can also see that the default request timeout is 60 seconds. That is short for long generations, so you will end up raising it on the &lt;code&gt;AIGatewayRoute&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  9-5. ExtProc arrives as a native sidecar
&lt;/h3&gt;

&lt;p&gt;Look at the Gateway Pod and the container layout is slightly unusual.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pod &lt;span class="nt"&gt;-n&lt;/span&gt; envoy-gateway-system &amp;lt;gateway-pod&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{range .spec.initContainers[*]}INIT {.name} {.restartPolicy}{"\n"}{end}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INIT ai-gateway-extproc Always
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ExtProc is not injected into the normal &lt;code&gt;containers&lt;/code&gt; list. It goes into &lt;code&gt;initContainers&lt;/code&gt; with &lt;code&gt;restartPolicy: Always&lt;/code&gt;, making it a &lt;strong&gt;native sidecar&lt;/strong&gt;. That is the Kubernetes 1.29 feature where, unlike a conventional sidecar, startup ordering is guaranteed and the container comes up before the main one. If ExtProc is not there the instant Envoy starts, every request returns 500, so this shape is required.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;containers:      envoy (envoyproxy/envoy:distroless-v1.38.1)
                 shutdown-manager (envoyproxy/gateway:v1.8.1)
initContainers:  ai-gateway-extproc (envoyproxy/ai-gateway-extproc:v1.0.0)  restartPolicy: Always
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  9-6. Connect to the real OpenAI
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-O&lt;/span&gt; https://raw.githubusercontent.com/envoyproxy/ai-gateway/main/examples/basic/openai.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Replace &lt;code&gt;apiKey: OPENAI_API_KEY&lt;/code&gt; with your own key and apply. Two things matter.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigateway.envoyproxy.io/v1beta1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackendSecurityPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-basic-openai-apikey&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;aigateway.envoyproxy.io&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;AIServiceBackend&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-basic-openai&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;APIKey&lt;/span&gt;
  &lt;span class="na"&gt;apiKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;secretRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-basic-openai-apikey&lt;/span&gt;
&lt;span class="nn"&gt;---&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io/v1alpha3&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackendTLSPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-basic-openai-tls&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gateway.envoyproxy.io"&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Backend&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-basic-openai&lt;/span&gt;
  &lt;span class="na"&gt;validation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;wellKnownCACertificates&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;System"&lt;/span&gt;
    &lt;span class="na"&gt;hostname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;api.openai.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BackendSecurityPolicy&lt;/code&gt; is the Kubernetes version of the key injection from section 5. Update the Secret and it takes effect within seconds, with no Pod restart.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;BackendTLSPolicy&lt;/code&gt; is mandatory for any external HTTPS upstream. Forget it and Envoy tries to speak plaintext to port 443 and fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  9-7. Raise the buffer limit
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;basic.yaml&lt;/code&gt; contains one unglamorous setting you will always need in production.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ClientTrafficPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;client-buffer-limit&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-basic&lt;/span&gt;
  &lt;span class="na"&gt;connection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;bufferLimit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;50Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Envoy Gateway's default buffer limit is &lt;strong&gt;32KiB&lt;/strong&gt;, which is nowhere near enough for LLM requests. Base64 an image into the body or send a long conversation history and it gets rejected. The sample raises it to 50MiB. It is easy to forget, so put it in from the start.&lt;/p&gt;

&lt;h3&gt;
  
  
  9-8. When you do not want API keys in Secrets
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;BackendSecurityPolicy&lt;/code&gt; supports &lt;strong&gt;automatic short-lived credential retrieval&lt;/strong&gt; for the major clouds.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Provider&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS Bedrock&lt;/td&gt;
&lt;td&gt;OIDC federated with AWS STS to issue temporary credentials&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure OpenAI&lt;/td&gt;
&lt;td&gt;Short-lived access tokens from Entra ID&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GCP Vertex AI&lt;/td&gt;
&lt;td&gt;Google STS via Workload Identity Federation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI and others&lt;/td&gt;
&lt;td&gt;API key stored in a Secret&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Providers without an OIDC story, OpenAI among them, cannot escape key management. But you can at least stop distributing those keys per application. Confine them to the gateway and both the blast radius of a rotation and the number of audit points collapse to one.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. Token-based rate limiting and quotas
&lt;/h2&gt;

&lt;p&gt;Time to look at the pay-later model from section 1-2 in real YAML. This part needs Redis, so it is not something &lt;code&gt;aigw run&lt;/code&gt; can demonstrate.&lt;/p&gt;

&lt;h3&gt;
  
  
  10-1. How it works
&lt;/h3&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F09-token-ratelimit-flow.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fenvoy-ai-gateway-hands-on%2Fdiagrams%2F09-token-ratelimit-flow.png" alt="Token-based rate limiting, checked outbound and billed inbound" width="800" height="842"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two ideas carry this.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Set the request-time cost to zero.&lt;/strong&gt; With &lt;code&gt;cost.request.number: 0&lt;/code&gt; the gateway checks that budget remains without spending any. The real consumption is charged after the response arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dynamic metadata is the handoff point.&lt;/strong&gt; ExtProc writes into a namespace called &lt;code&gt;io.envoy.ai_gateway&lt;/code&gt;, and &lt;code&gt;BackendTrafficPolicy&lt;/code&gt; reads from it. &lt;strong&gt;That namespace is fixed&lt;/strong&gt; and cannot be changed. Typo it and rate limiting quietly stops working.&lt;/p&gt;

&lt;h3&gt;
  
  
  10-2. Configuration
&lt;/h3&gt;

&lt;p&gt;First, declare on the &lt;code&gt;AIGatewayRoute&lt;/code&gt; what to pull out of the response and which key to store it under.&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;llmRequestCosts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadataKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_input_token&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;InputToken&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadataKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_cached_input_token&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;CachedInputToken&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadataKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_output_token&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;OutputToken&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;metadataKey&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_total_token&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;TotalToken&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then consume those keys from a &lt;code&gt;BackendTrafficPolicy&lt;/code&gt;.&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.envoyproxy.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;BackendTrafficPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;token-ratelimit&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;targetRefs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;envoy-ai-gateway-token-ratelimit&lt;/span&gt;
      &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Gateway&lt;/span&gt;
      &lt;span class="na"&gt;group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gateway.networking.k8s.io&lt;/span&gt;
  &lt;span class="na"&gt;rateLimit&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;Global&lt;/span&gt;
    &lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;clientSelectors&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="c1"&gt;# a separate budget per distinct x-tenant-id value&lt;/span&gt;
                &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;x-tenant-id&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;Distinct&lt;/span&gt;
          &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;
            &lt;span class="na"&gt;unit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Hour&lt;/span&gt;
          &lt;span class="na"&gt;cost&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Number&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
            &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Metadata&lt;/span&gt;
              &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;io.envoy.ai_gateway&lt;/span&gt;
                &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;llm_input_token&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The field is named &lt;code&gt;limit.requests&lt;/code&gt;, but because &lt;code&gt;cost&lt;/code&gt; comes from metadata this is effectively "10,000 &lt;strong&gt;tokens&lt;/strong&gt; per hour." To give input, output, and total their own budgets, repeat the same rule shape with a different &lt;code&gt;key&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;type: Distinct&lt;/code&gt; means one bucket per header value, which is how you get per-tenant or per-user limits in a single line. Swap in &lt;code&gt;x-ai-eg-model&lt;/code&gt; if you want per-model limits instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  10-3. Rate limiting versus quotas
&lt;/h3&gt;

&lt;p&gt;There is a separate CRD called &lt;code&gt;QuotaPolicy&lt;/code&gt; that is easy to confuse with this. The API landed in v0.6 and it only started actually enforcing anything in v0.7. It is still &lt;code&gt;v1alpha1&lt;/code&gt; and outside the v1.0 stability guarantee.&lt;/p&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;Usage-based rate limiting&lt;/th&gt;
&lt;th&gt;QuotaPolicy&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;What it controls&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Rate.&lt;/strong&gt; Consumption pace per unit time&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Cumulative budget.&lt;/strong&gt; Total spend over a window&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typical use&lt;/td&gt;
&lt;td&gt;Stop a runaway batch from crowding everyone out&lt;/td&gt;
&lt;td&gt;"This team gets one million tokens a month"&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Where you write it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BackendTrafficPolicy&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;QuotaPolicy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What it attaches to&lt;/td&gt;
&lt;td&gt;Gateway or HTTPRoute&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AIServiceBackend&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;QuotaPolicy&lt;/code&gt; can hold a budget per model, and CEL lets you weight token types differently.&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;perModelQuotas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;modelName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;gpt-4&lt;/span&gt;
    &lt;span class="na"&gt;quota&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="c1"&gt;# cached input counts as one tenth, output counts sixfold&lt;/span&gt;
      &lt;span class="na"&gt;costExpression&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_tokens&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;cached_input_tokens&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;10u&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;+&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;output_tokens&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6u"&lt;/span&gt;
      &lt;span class="na"&gt;defaultBucket&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10000&lt;/span&gt;
        &lt;span class="na"&gt;duration&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1h"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One trap. The &lt;code&gt;modelName&lt;/code&gt; in &lt;code&gt;perModelQuotas&lt;/code&gt; must match the &lt;code&gt;modelNameOverride&lt;/code&gt; on the &lt;code&gt;AIGatewayRoute&lt;/code&gt;, or the quota is &lt;strong&gt;silently ignored&lt;/strong&gt;. No error, no warning. If you set &lt;code&gt;modelNameOverride: mock-gpt&lt;/code&gt; in section 7, write &lt;code&gt;mock-gpt&lt;/code&gt; on the quota side too.&lt;/p&gt;




&lt;h2&gt;
  
  
  11. Features that matter in production
&lt;/h2&gt;

&lt;p&gt;An inventory, including things this article has not touched.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Why you want it&lt;/th&gt;
&lt;th&gt;What implements it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Schema translation&lt;/td&gt;
&lt;td&gt;Clients only ever speak OpenAI format&lt;/td&gt;
&lt;td&gt;Request path plus &lt;code&gt;AIServiceBackend.schema&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Provider fallback&lt;/td&gt;
&lt;td&gt;Automatic evacuation to another provider during an outage&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;priority&lt;/code&gt; plus &lt;code&gt;BackendTrafficPolicy.retry&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model name virtualization&lt;/td&gt;
&lt;td&gt;Naming differences between providers never reach the app&lt;/td&gt;
&lt;td&gt;&lt;code&gt;modelNameOverride&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Upstream auth&lt;/td&gt;
&lt;td&gt;Keys leave the application. Short-lived tokens on the clouds&lt;/td&gt;
&lt;td&gt;&lt;code&gt;BackendSecurityPolicy&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token limits and quotas&lt;/td&gt;
&lt;td&gt;Stop runaways on both rate and cumulative spend&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;BackendTrafficPolicy&lt;/code&gt; / &lt;code&gt;QuotaPolicy&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Observability&lt;/td&gt;
&lt;td&gt;Metrics, traces, and logs following the OTel GenAI conventions&lt;/td&gt;
&lt;td&gt;Prometheus / OTLP&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP gateway&lt;/td&gt;
&lt;td&gt;Aggregating MCP servers, filtering tools, OAuth&lt;/td&gt;
&lt;td&gt;&lt;code&gt;MCPRoute&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;InferencePool integration&lt;/td&gt;
&lt;td&gt;Smarter routing to self-hosted vLLM on your own GPUs&lt;/td&gt;
&lt;td&gt;Gateway API Inference Extension&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The set of supported endpoints has grown a lot since v0.1.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /v1/chat/completions             POST /v1/embeddings
POST /v1/completions                  POST /v1/images/generations
POST /v1/responses                    POST /v1/audio/transcriptions
POST /v1/responses/input_tokens       POST /v1/audio/translations
POST /anthropic/v1/messages           POST /cohere/v2/rerank
POST /anthropic/v1/messages/count_tokens
GET  /v1/models
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because &lt;code&gt;/anthropic/v1/messages&lt;/code&gt; is there, &lt;strong&gt;an application written against the Anthropic SDK can go through the gateway unchanged&lt;/strong&gt;. The ingress is not locked to OpenAI format.&lt;/p&gt;

&lt;p&gt;InferencePool deserves a mention. If you own GPUs and run a fleet of vLLM replicas, plain round-robin is close to the worst possible choice. Sending a request to the node whose KV cache already holds the conversation is fast, and round-robin will happily throw it at a node that misses. The Endpoint Picker from Gateway API Inference Extension routes based on which replica currently holds the conversation. Envoy AI Gateway has supported Inference Extension v1.0 since v0.4, and an &lt;code&gt;AIGatewayRoute&lt;/code&gt; can reference an &lt;code&gt;InferencePool&lt;/code&gt;. If you self-host, look into this.&lt;/p&gt;




&lt;h2&gt;
  
  
  12. Deciding whether to adopt it
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Worth it
&lt;/h3&gt;

&lt;p&gt;With two or more providers, translation and fallback alone pay for the thing. If you need to attribute spend, instrumenting every application by hand instead is not realistic. And if you already run Envoy Gateway, the marginal cost is mostly the operational knowledge.&lt;/p&gt;

&lt;h3&gt;
  
  
  Be careful
&lt;/h3&gt;

&lt;p&gt;With one provider and one application, using the SDK directly is faster. A gateway is one more hop and pure liability.&lt;/p&gt;

&lt;p&gt;If you are not on Kubernetes, &lt;code&gt;aigw run&lt;/code&gt; works standalone, but rate limiting and quotas assume Redis and Envoy Gateway's rate limit service. The full feature set is Kubernetes-only.&lt;/p&gt;

&lt;p&gt;If your cluster is below 1.32, this becomes a cluster upgrade conversation first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Know the failure modes up front
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Failure mode&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Buffer limit left at 32KiB&lt;/td&gt;
&lt;td&gt;Long prompts or image payloads get rejected&lt;/td&gt;
&lt;td&gt;Raise &lt;code&gt;bufferLimit&lt;/code&gt; via &lt;code&gt;ClientTrafficPolicy&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Missing &lt;code&gt;retriable-status-codes&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Fallback silently does nothing&lt;/td&gt;
&lt;td&gt;Always include it in &lt;code&gt;triggers&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unimplemented &lt;code&gt;schema&lt;/code&gt; pairing&lt;/td&gt;
&lt;td&gt;500 with &lt;code&gt;unsupported API schema&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Check the ingress path and egress &lt;code&gt;schema&lt;/code&gt; as a pair&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;modelNameOverride&lt;/code&gt; and &lt;code&gt;QuotaPolicy.modelName&lt;/code&gt; disagree&lt;/td&gt;
&lt;td&gt;The quota is ignored with no error&lt;/td&gt;
&lt;td&gt;Make the names match&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Typo in the dynamic metadata namespace&lt;/td&gt;
&lt;td&gt;Rate limiting quietly stops working&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;io.envoy.ai_gateway&lt;/code&gt; is fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Default 60 second timeout&lt;/td&gt;
&lt;td&gt;Long generations get cut off midway&lt;/td&gt;
&lt;td&gt;Raise &lt;code&gt;timeouts&lt;/code&gt; on the &lt;code&gt;AIGatewayRoute&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model missing from any route&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;td&gt;Fold new model adoption into your process&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gateway becomes a SPOF&lt;/td&gt;
&lt;td&gt;All LLM traffic stops&lt;/td&gt;
&lt;td&gt;Multiple replicas, PDB, regional redundancy&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last one is not something to wave away. Routing every application's LLM calls through one place means that when it goes down, everything goes down. Whether the observability and governance you get back is worth that depends on the size of your organization.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version notes
&lt;/h3&gt;

&lt;p&gt;v0.6 shipped two breaking changes. &lt;code&gt;AIGatewayRoute.spec.filterConfig&lt;/code&gt; was removed and moved to &lt;code&gt;GatewayConfig&lt;/code&gt;, and the version-as-prefix behavior on &lt;code&gt;VersionedAPISchema&lt;/code&gt; was dropped in favor of &lt;code&gt;prefix&lt;/code&gt;. Copying YAML out of older blog posts will bite you.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;The thing I most want to leave you with is that &lt;strong&gt;you can try all of this with &lt;code&gt;aigw run&lt;/code&gt; before you ever stand up Kubernetes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Sections 5 through 8 of this article did not even use Docker. A machine with Go and the Python standard library was enough to watch key injection, schema translation, fallback, model name virtualization, and MCP aggregation all happen for real. And because the config YAML is byte-for-byte the same CRDs Kubernetes uses, it carries straight over to a cluster.&lt;/p&gt;

&lt;p&gt;The difference in how fast things click is substantial. Write a mock server, run &lt;code&gt;aigw run&lt;/code&gt; against it, and go from there.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>kubernetes</category>
      <category>envoy</category>
      <category>llm</category>
    </item>
    <item>
      <title>Let's Play Shogi: I Turned My GitHub Profile README Into a Playable Board</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Thu, 13 Aug 2026 11:18:30 +0000</pubDate>
      <link>https://dev.to/kanywst/lets-play-shogi-i-turned-my-github-profile-readme-into-a-playable-board-333c</link>
      <guid>https://dev.to/kanywst/lets-play-shogi-i-turned-my-github-profile-readme-into-a-playable-board-333c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-10-02):&lt;/strong&gt; the board has moved out of my profile README into its own repository, &lt;a href="https://github.com/0-draft/shogi" rel="noopener noreferrer"&gt;0-draft/shogi&lt;/a&gt;. Everything below still describes how it works; only the address changed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (2026-11-08):&lt;/strong&gt; the board is retired. In the months it was up, nobody but me played a move, so the repository is frozen and will be archived; the code stays readable at &lt;a href="https://github.com/0-draft/shogi" rel="noopener noreferrer"&gt;0-draft/shogi&lt;/a&gt;, but new moves no longer do anything.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;There is a shogi board on my GitHub profile. Click a piece and you play a move.&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%2F71dfau8469z0llxtha5i.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%2F71dfau8469z0llxtha5i.png" alt="The shogi board on my GitHub profile" width="799" height="466"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kanywst" rel="noopener noreferrer"&gt;https://github.com/kanywst&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Your opponent is not me. It is whoever wanders by next. Someone plays for Black, someone else plays for White, and a game slowly happens between strangers.&lt;/p&gt;

&lt;p&gt;This started because I could not decide what to put on my profile README. A skill list, GitHub Stats badges, the snake eating my contribution graph. I have seen all of them, and I look at all of them exactly once. I wanted something a visitor could actually do.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you have never played shogi
&lt;/h2&gt;

&lt;p&gt;Two things from the game matter for the rest of this post.&lt;/p&gt;

&lt;p&gt;Squares are named by file and rank, so &lt;code&gt;7g&lt;/code&gt; is one specific square, and &lt;code&gt;7g7f&lt;/code&gt; means the piece on &lt;code&gt;7g&lt;/code&gt; moves to &lt;code&gt;7f&lt;/code&gt;. And unlike chess, a captured piece does not leave the game: it becomes yours, and on a later turn you can drop it back onto almost any empty square instead of moving something. That second rule is what makes the number of possible moves explode, which turns out to decide the whole interface.&lt;/p&gt;

&lt;h2&gt;
  
  
  A profile README is just a repository
&lt;/h2&gt;

&lt;p&gt;GitHub has a rule where a repository named after your username gets its &lt;code&gt;README.md&lt;/code&gt; rendered at the top of your profile page. For me that is &lt;code&gt;kanywst/kanywst&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;It is an ordinary repository, so GitHub Actions runs in it. If an Action rewrites &lt;code&gt;README.md&lt;/code&gt; and commits, the profile page changes with it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kanywst/kanywst (an ordinary repository)
  └─ README.md ──────&amp;gt; rendered at github.com/kanywst
       ▲
       └── GitHub Actions can rewrite it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  No JavaScript runs in a README
&lt;/h2&gt;

&lt;p&gt;GitHub sanitizes the README before rendering it. You can check exactly what happens through the markdown API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api /markdown &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gfm &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="nv"&gt;text&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'&amp;lt;div style="color:red" onclick="x()"&amp;gt;A&amp;lt;/div&amp;gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&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 html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;div&amp;gt;&lt;/span&gt;A&lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;&lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;script&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt;alert(1)&lt;span class="ni"&gt;&amp;amp;lt;&lt;/span&gt;/script&lt;span class="ni"&gt;&amp;amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;style&lt;/code&gt; and &lt;code&gt;onclick&lt;/code&gt; attributes are gone, and the script tag comes back escaped, so it renders as visible text instead of running. "Click this and something happens" is not available in the usual way.&lt;/p&gt;

&lt;p&gt;What survives is roughly links, images, and tables.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fshogi-on-github-profile%2Fdiagrams%2F01-what-a-readme-allows.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fshogi-on-github-profile%2Fdiagrams%2F01-what-a-readme-allows.png" alt="What a README allows and what gets stripped" width="800" height="346"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So the only action a visitor can take is following a link, and the only way to receive input is a URL.&lt;/p&gt;

&lt;h2&gt;
  
  
  An Issue can be pre-filled from a URL
&lt;/h2&gt;

&lt;p&gt;GitHub's new-issue page accepts query parameters, so you can open it with the title already typed in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://github.com/kanywst/kanywst/issues/new?title=shogi%7Cmv+7g7f
                                              └────────┬────────┘
                              opens the form with "shogi|mv 7g7f" already in it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The visitor presses the green "Submit new issue" button. That is the whole interaction. Nothing to type.&lt;/p&gt;

&lt;p&gt;Once the issue exists, an &lt;code&gt;issues.opened&lt;/code&gt; event fires, and that starts the Action. The whole round trip looks like this.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fshogi-on-github-profile%2Fdiagrams%2F02-click-to-move.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fshogi-on-github-profile%2Fdiagrams%2F02-click-to-move.png" alt="From a click on the board to an updated board" width="800" height="847"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Following one link is one move.&lt;/p&gt;

&lt;h2&gt;
  
  
  The board is 81 links
&lt;/h2&gt;

&lt;p&gt;The board is a &lt;code&gt;&amp;lt;table&amp;gt;&lt;/code&gt; where each square is a &lt;code&gt;&amp;lt;td&amp;gt;&lt;/code&gt;, and every square holds an image wrapped in a link.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;td&amp;gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"new-issue URL"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"piece image"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"7g black pawn"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/a&amp;gt;&amp;lt;/td&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The piece images are not generated. There are 58 static SVGs sitting in &lt;code&gt;.github/koma/&lt;/code&gt;, and the board state is expressed purely by which files get placed where. White's pieces are Black's pieces rotated 180 degrees, because orientation is the only way to tell the two sides apart on a shogi board.&lt;/p&gt;

&lt;p&gt;Depending on the state of a square, the link points at one of three things.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;State of the square&lt;/th&gt;
&lt;th&gt;Link target&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A square the selected piece can reach&lt;/td&gt;
&lt;td&gt;`?title=shogi\&lt;/td&gt;
&lt;td&gt;mv 7g7f`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A piece belonging to the side to move&lt;/td&gt;
&lt;td&gt;`?title=shogi\&lt;/td&gt;
&lt;td&gt;sel 7g`&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Anything else&lt;/td&gt;
&lt;td&gt;&lt;code&gt;#shogi&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;nothing happens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Even a dead square needs that third link. If you leave an image unwrapped, GitHub wraps it for you and points it at the image file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;target=&lt;/span&gt;&lt;span class="s"&gt;"_blank"&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"noopener noreferrer"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"y.svg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"y.svg"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A visitor who taps an empty square would land on a raw SVG with no way back to the profile. Sending them to &lt;code&gt;#shogi&lt;/code&gt; costs nothing and keeps them on the board.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two clicks, not one
&lt;/h2&gt;

&lt;p&gt;The obvious design is one link per legal move, so a single click plays a move. Shogi has too many moves for that. I counted 1125 positions from random games: the median was 40 legal moves and the maximum was 167. Pieces in hand are the reason. Holding one pawn means every empty square is potentially another move.&lt;/p&gt;

&lt;p&gt;There are only 81 squares to hang links on, so that does not fit. So the move takes two steps instead.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fshogi-on-github-profile%2Fdiagrams%2F03-two-clicks.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fshogi-on-github-profile%2Fdiagrams%2F03-two-clicks.png" alt="Select a piece, then click one of the red circles" width="800" height="1320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Now the number of links on screen is either how many pieces you own or how many squares the selected piece can reach, and neither can exceed the number of squares. It ends up feeling like &lt;a href="https://github.com/marcizhu/marcizhu" rel="noopener noreferrer"&gt;marcizhu/marcizhu&lt;/a&gt;, which does the same thing with chess.&lt;/p&gt;

&lt;p&gt;Illegal moves never get a circle, so the rules live entirely in what the board offers you. To check that the move generator was not lying, I counted the legal moves in the starting position and got 30, which is the known value for shogi.&lt;/p&gt;

&lt;h2&gt;
  
  
  Red circles alone did not communicate anything
&lt;/h2&gt;

&lt;p&gt;This part I fixed after publishing. Someone looked at the board while a piece was selected and told me the circle made no sense.&lt;/p&gt;

&lt;p&gt;They were right. One red circle floats in the middle of the board, and nothing on the board says which piece it belongs to. The real problem was that &lt;strong&gt;the selected piece still looked like an ordinary piece&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In a normal web app you would put a CSS class on the selected square and be done. Here the sanitizer eats &lt;code&gt;style&lt;/code&gt;, as the API call earlier showed. The only thing left under my control is which file goes in &lt;code&gt;&amp;lt;img src&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So every piece image now has a second version with a red frame around the square. The frame is the same red as the destination circles, so "this piece goes to those circles" connects at a glance.&lt;/p&gt;

&lt;p&gt;Adding 28 images to replace one line of CSS is a bad trade everywhere except here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;No server, no database. The position lives in a single JSON file committed to the repository, and every move is recorded as a commit named &lt;code&gt;shogi: move by @someone&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The part I like is that the players swap out asynchronously. Whoever plays Black and whoever plays White are probably different people who will never meet, and the game continues anyway.&lt;/p&gt;

&lt;p&gt;As a thing to put on a profile, I think it says more than a skill list does.&lt;/p&gt;

&lt;p&gt;If you pass by, play a move.&lt;/p&gt;

&lt;p&gt;Let's play shogi.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/0-draft/shogi" rel="noopener noreferrer"&gt;https://github.com/0-draft/shogi&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>github</category>
      <category>python</category>
      <category>automation</category>
    </item>
    <item>
      <title>Mapping MCP, A2A, and ACP: Telling AI Agent Protocols Apart in 2026</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Sun, 28 Jun 2026 11:51:47 +0000</pubDate>
      <link>https://dev.to/kanywst/mapping-mcp-a2a-and-acp-telling-ai-agent-protocols-apart-in-2026-1hha</link>
      <guid>https://dev.to/kanywst/mapping-mcp-a2a-and-acp-telling-ai-agent-protocols-apart-in-2026-1hha</guid>
      <description>&lt;h2&gt;
  
  
  How this started: I went looking for ACP and it was gone
&lt;/h2&gt;

&lt;p&gt;I wanted to sort out the difference between MCP, ACP, and A2A. MCP I hear about constantly. A2A I knew by name. ACP I barely knew, so I opened its official repo (&lt;code&gt;github.com/i-am-bee/acp&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Here is what greeted me at the top of the README:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;ACP is now part of A2A under the Linux Foundation!&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The repo was archived on August 27, 2025 and set to read-only. In other words, ACP as a standalone protocol was over. It had been folded into A2A.&lt;/p&gt;

&lt;p&gt;A lot of explainers written in the first half of 2025 are built around "pick the right one of MCP, ACP, and A2A." But in 2026 those three are not three equals. There are two pillars, MCP and A2A, and ACP is the history of something that flowed into one of them.&lt;/p&gt;

&lt;p&gt;So in this post I am going to draw a map first: "an agent only reaches out in two directions." Then I will place MCP and A2A on that map, and only after that explain where ACP sat and where it went. Read it top to bottom and the loose terms should snap onto a single picture.&lt;/p&gt;

&lt;p&gt;You need almost no background. If you have ever had an LLM call an API, you can follow this.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prereq 1: what actually makes something an "AI agent"
&lt;/h2&gt;

&lt;p&gt;Before the protocols, let me line up the words. "LLM" and "AI agent" get used interchangeably, but they are not the same thing.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;LLM on its own&lt;/strong&gt; is close to a function that takes text and returns text. Smart, but it cannot do anything by itself. It cannot browse the web, read a file, or run code. It just answers what you ask.&lt;/p&gt;

&lt;p&gt;An &lt;strong&gt;AI agent&lt;/strong&gt; uses that LLM as a brain, and works toward a goal: it decides for itself, picks up tools, and loops as many times as needed. Tell it "book me a flight for next week's Tokyo trip" and it searches for seats, compares prices, and hits the booking API. It drives that whole loop on its own.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F01-llm-vs-agent.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F01-llm-vs-agent.png" alt="LLM on its own vs an AI agent" width="800" height="682"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The thing to hold onto: an agent is &lt;strong&gt;a brain (the LLM) plus hands and feet (tools)&lt;/strong&gt;. The brain alone does nothing. How you wire up those hands and feet is where the first protocol shows up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Prereq 2: an agent only reaches out in two directions
&lt;/h2&gt;

&lt;p&gt;When an agent talks to "the outside world," it really only goes in two directions. These two directions are the map this whole post hangs on.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Downward, to use a tool&lt;/strong&gt; (the vertical direction): a search engine, a database, an internal API, the filesystem. Things the agent "uses" as its own hands and feet. There is a master/servant relationship.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sideways, to team up with another agent&lt;/strong&gt; (the horizontal direction): an agent built by another team, an agent at another company. A peer you "hand work to" as an equal.&lt;/li&gt;
&lt;/ol&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F02-two-directions.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F02-two-directions.png" alt="The two directions an agent reaches out: vertical tools, horizontal agents" width="799" height="290"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These two directions are completely different in nature.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vertical (a tool)&lt;/strong&gt;: the other side is just a function. You pass input, you get output. It does not think. It does what it is told.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Horizontal (an agent)&lt;/strong&gt;: the other side has a brain too. You hand over "what you want done" (the intent), and it figures out how on its own. Sometimes it takes several round trips.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This &lt;strong&gt;vertical = MCP, horizontal = A2A&lt;/strong&gt; split is the standard way to carve things up as of 2026. And this is not someone's hot take: the A2A spec itself has an "A2A and MCP" section that frames MCP as how an agent uses a tool and A2A as how agents collaborate as peers, and calls them complementary, not competitors.&lt;/p&gt;

&lt;p&gt;So let me take the two one at a time.&lt;/p&gt;




&lt;h2&gt;
  
  
  MCP: the standard for the vertical direction (agent ↔ tool)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The problem MCP solved: N×M wiring hell
&lt;/h3&gt;

&lt;p&gt;MCP (Model Context Protocol) is an open protocol Anthropic published in November 2024. In one line, it is &lt;strong&gt;a standard way to give an LLM tools&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Why standardize at all? Picture a world without a standard. Say there are N kinds of LLM (Claude, GPT, Gemini, ...) and M tools you want to connect (Slack, GitHub, Postgres, ...). With no standard, you end up writing a custom connector for every pair. That is N×M wires.&lt;/p&gt;

&lt;p&gt;MCP drops one &lt;strong&gt;common socket&lt;/strong&gt; in the middle. The tool side builds an "MCP server" once and every LLM can use it. The LLM side implements an "MCP client" once and can reach every tool. The wiring drops to N+M.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F03-mcp-nxm-wiring.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F03-mcp-nxm-wiring.png" alt="MCP collapses N x M wiring into one common socket" width="800" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before USB, every device needed its own cable. USB standardized the socket. MCP is "the USB between an LLM and its tools."&lt;/p&gt;

&lt;h3&gt;
  
  
  What is inside MCP: three roles and the primitives
&lt;/h3&gt;

&lt;p&gt;MCP runs on top of JSON-RPC 2.0 (a plain convention for exchanging requests and responses as JSON) and keeps session state as it goes. There are three roles.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Host&lt;/td&gt;
&lt;td&gt;The app the user actually touches (Claude Desktop, an IDE, your own agent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Client&lt;/td&gt;
&lt;td&gt;Lives inside the Host and holds a 1-to-1 connection to a single server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Server&lt;/td&gt;
&lt;td&gt;The side that provides tools and data (a Slack server, a DB server, and so on)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F04-mcp-roles.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F04-mcp-roles.png" alt="MCP host, client, server, and the primitives a server exposes" width="799" height="282"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A server exposes mainly three things (called primitives). Nail these down and MCP's shape comes into focus.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tools&lt;/strong&gt;: "functions" the LLM can call. Like &lt;code&gt;create_issue(title, body)&lt;/code&gt;: pass arguments, it does something and returns a result. The LLM calls these actively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resources&lt;/strong&gt;: "data" you let the LLM read. File contents, DB records, logs. Identified by a URI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompts&lt;/strong&gt;: prepared "instruction templates." The user picks one to use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are two ways to connect (the transport): &lt;strong&gt;stdio&lt;/strong&gt;, which launches the server as a local process and talks over standard in/out, and &lt;strong&gt;Streamable HTTP&lt;/strong&gt;, which connects over HTTP. Local tools use the former, remote services the latter.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where MCP is now (2026)
&lt;/h3&gt;

&lt;p&gt;MCP has not stood still. The spec gets updated often, and the latest as of 2026 is the &lt;strong&gt;2025-11-25 revision&lt;/strong&gt;. Read the trend of the changes and the direction is clear.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2025-06-18 revision&lt;/strong&gt;: security took center stage. It formally classifies an MCP server as an OAuth 2.0 "resource server" and binds tokens to a specific server. It also added structured output (&lt;code&gt;structuredContent&lt;/code&gt;) and &lt;strong&gt;elicitation&lt;/strong&gt; (asking the user for more input mid-conversation).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2025-11-25 revision&lt;/strong&gt;: authorization got stronger still (OpenID Connect Discovery support, among others). It also added &lt;strong&gt;tasks&lt;/strong&gt; (experimental) for tracking long-running work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;MCP started as "just connect the tools" and has been shifting its weight toward "who is allowed to use this tool" (authn and authz). Once tools include internal DBs and payment APIs, that is the natural progression.&lt;/p&gt;




&lt;h2&gt;
  
  
  A2A: the standard for the horizontal direction (agent ↔ agent)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Where MCP runs out of road
&lt;/h3&gt;

&lt;p&gt;MCP connects the tools. But what if &lt;strong&gt;the other side is not a tool, but another agent&lt;/strong&gt;?&lt;/p&gt;

&lt;p&gt;Say your company's travel-booking agent wants to hand work to a partner's "flight-booking agent." Now the other side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;has its own brain (an LLM); it is not a tool that executes your command one-to-one.&lt;/li&gt;
&lt;li&gt;given an &lt;strong&gt;intent&lt;/strong&gt; like "lock in a flight to Osaka next week with a seat, under 30,000 yen," decides for itself which booking API to hit and how.&lt;/li&gt;
&lt;li&gt;does not finish in one round trip; it asks back, "that day is full, how about within three days either side?" (a multi-turn exchange).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The MCP "call a function once" model cannot express this. This is where A2A (Agent2Agent) comes in.&lt;/p&gt;

&lt;h3&gt;
  
  
  How A2A came to be
&lt;/h3&gt;

&lt;p&gt;A2A is a protocol Google announced in April 2025. What happened next moved fast.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;On June 23, 2025 it became a &lt;strong&gt;formal project under the Linux Foundation&lt;/strong&gt; (backed by 100+ companies). That means it stopped being any one vendor's property and became a neutral standard.&lt;/li&gt;
&lt;li&gt;On March 12, 2026 it reached &lt;strong&gt;v1.0.0&lt;/strong&gt;. A stable release.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A2A's goal is to let &lt;strong&gt;opaque agents&lt;/strong&gt; built by different vendors and frameworks collaborate without touching each other's internal state or tools. An agent whose source you cannot &lt;code&gt;import&lt;/code&gt;, running somewhere else, owned by someone else. And you can still hand it work.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is inside A2A: the Agent Card and the Task
&lt;/h3&gt;

&lt;p&gt;The first thing to grasp in A2A is the &lt;strong&gt;Agent Card&lt;/strong&gt; (the agent's business card). Each agent publishes it as JSON at &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt;, and it says:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what it can do (skills and capabilities)&lt;/li&gt;
&lt;li&gt;where to connect (the endpoint)&lt;/li&gt;
&lt;li&gt;what auth you need to call it (Bearer token? mTLS?)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The caller reads this card first. It learns "what this thing can do and how to authenticate" at runtime, then sends the real request. No need to pre-build against the other side's API contract.&lt;/p&gt;

&lt;p&gt;The unit of work is the &lt;strong&gt;Task&lt;/strong&gt;. A task has a unique ID and moves through states (&lt;code&gt;submitted&lt;/code&gt; → &lt;code&gt;working&lt;/code&gt; → &lt;code&gt;input-required&lt;/code&gt; → &lt;code&gt;completed&lt;/code&gt;, and so on). Even slow work can proceed while you follow its state.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F05-a2a-task-flow.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F05-a2a-task-flow.png" alt="A2A flow: read the Agent Card, then run a stateful task" width="800" height="768"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This "1) read the card, 2) send intent, 3) work it out in a back-and-forth" is A2A's basic rhythm. Compare it to MCP's "call a function once and get a result" and you can feel the difference in cadence.&lt;/p&gt;

&lt;h3&gt;
  
  
  A2A is also about the N×N problem
&lt;/h3&gt;

&lt;p&gt;A2A's real value, just like MCP's, is preventing combinatorial blowup.&lt;/p&gt;

&lt;p&gt;Every company has agents and wants to hand work back and forth. With no standard, every pair needs its own connector. The more agents there are, the more the combinations explode (N×N). If everyone aligns on A2A's single card format and single calling convention, &lt;strong&gt;you can hand work to an agent you have never met, with no prior arrangement&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F06-a2a-nxn.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F06-a2a-nxn.png" alt="A2A collapses N x N agent wiring into one convention" width="800" height="573"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same shape as USB-C. The technology itself is not new; the value is that &lt;strong&gt;everyone agreed on the same socket&lt;/strong&gt;. Vendors have a motive here too: they do not want to expose raw APIs, but they will open up through an "agent" front door.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where ACP sat, and where it went
&lt;/h2&gt;

&lt;p&gt;So far, two pillars: MCP (vertical) and A2A (horizontal). So where does the ACP from the opening fit?&lt;/p&gt;

&lt;h3&gt;
  
  
  What ACP was
&lt;/h3&gt;

&lt;p&gt;ACP (Agent Communication Protocol) is a protocol IBM Research announced in March 2025. It was built to drive BeeAI, an open-source agent platform (&lt;code&gt;i-am-bee&lt;/code&gt;). Its purpose was nearly the same as A2A's &lt;strong&gt;horizontal&lt;/strong&gt; lane: standardizing agent-to-agent communication.&lt;/p&gt;

&lt;p&gt;There were small differences from A2A. ACP was REST-native and lightweight, and declared an agent's capabilities at build time (in contrast to A2A reading a card at runtime). But on the big picture, it was &lt;strong&gt;a competing protocol aiming at the same "agent-to-agent" slot as A2A&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  And then it merged into A2A
&lt;/h3&gt;

&lt;p&gt;Two standards (A2A and ACP) in the horizontal lane leaves the field asking "which one do I align with?" When a standard splits, the whole benefit of a standard (everyone converging) evaporates.&lt;/p&gt;

&lt;p&gt;So in August 2025 the call was made. The IBM/BeeAI team wound down ACP's independent development and announced they would &lt;strong&gt;contribute its technology and know-how directly into A2A&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;August 25-29, 2025: "ACP Joins Forces with A2A" is announced officially.&lt;/li&gt;
&lt;li&gt;August 27, 2025: &lt;code&gt;github.com/i-am-bee/acp&lt;/code&gt; is archived (read-only). The README leads with "ACP is now part of A2A" and a pointer to a migration guide.&lt;/li&gt;
&lt;li&gt;BeeAI itself lives on, but its communication moves from ACP to &lt;strong&gt;A2A&lt;/strong&gt; (with a migration guide provided).&lt;/li&gt;
&lt;li&gt;An IBM Research representative joined A2A's Technical Steering Committee, alongside Google, Microsoft, AWS, Cisco, and others.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F07-acp-timeline.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F07-acp-timeline.png" alt="ACP timeline: announced, runs alongside, merges into A2A, archived" width="800" height="2026"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;in 2026, lining up ACP as a current, independent protocol next to MCP and A2A is not accurate&lt;/strong&gt;. The right read is historical: "the horizontal lane briefly split in two, then consolidated onto A2A." If you are building something new, pick A2A for the horizontal lane. There is no reason to adopt ACP today.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Aside: the acronym "ACP" has another, unrelated meaning. The Cisco-led AGNTCY initiative uses "Agent Connect Protocol." It has nothing to do with IBM's Agent Communication Protocol, and online explainers sometimes conflate them. The ACP in this post always means the IBM/BeeAI one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The whole picture: who does what in a multi-agent system
&lt;/h2&gt;

&lt;p&gt;So how do MCP and A2A live together inside one system? A concrete example is the fastest way to see it.&lt;/p&gt;

&lt;p&gt;Picture a system that takes the request "arrange my whole business trip." The shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrator agent&lt;/strong&gt;: understands the request and parcels work out to specialist agents. The conductor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flight agent / Hotel agent&lt;/strong&gt;: the specialists. Each runs independently.&lt;/li&gt;
&lt;li&gt;Each agent uses external tools (a seat-search API, a booking DB) to do its own job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now draw the lines. &lt;strong&gt;Between the conductor and the specialists (the sideways link) is A2A.&lt;/strong&gt; &lt;strong&gt;Between each agent and its tools (the downward use) is MCP.&lt;/strong&gt; That maps straight onto the diagram.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F08-multi-agent-system.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F08-multi-agent-system.png" alt="MCP and A2A coexisting in a multi-agent travel system" width="800" height="677"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The colors split the roles for you. &lt;strong&gt;The horizontal links between the pink agents are A2A; the vertical links from an agent down to a green tool are MCP.&lt;/strong&gt; You cannot build this with only one of them. You need both the sideways collaboration and the downward tool use. Which is exactly why the official line, "MCP and A2A are complementary, not competitors," lands so cleanly in this picture.&lt;/p&gt;




&lt;h2&gt;
  
  
  A cheat sheet and a decision flow
&lt;/h2&gt;

&lt;p&gt;Let me compress everything down to something you can use while your hands are on the keyboard.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cheat sheet
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Angle&lt;/th&gt;
&lt;th&gt;MCP&lt;/th&gt;
&lt;th&gt;A2A&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Direction&lt;/td&gt;
&lt;td&gt;Vertical (agent → tool)&lt;/td&gt;
&lt;td&gt;Horizontal (agent ↔ agent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Other side&lt;/td&gt;
&lt;td&gt;A plain function (does not think)&lt;/td&gt;
&lt;td&gt;An autonomous agent (has a brain)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you pass&lt;/td&gt;
&lt;td&gt;A function call (arguments)&lt;/td&gt;
&lt;td&gt;An intent (what you want done)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Interaction&lt;/td&gt;
&lt;td&gt;Usually call once, get a result&lt;/td&gt;
&lt;td&gt;Multi-turn, stateful work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Discovery&lt;/td&gt;
&lt;td&gt;Connect via server config&lt;/td&gt;
&lt;td&gt;Read the Agent Card at runtime&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Origin / steward&lt;/td&gt;
&lt;td&gt;Anthropic&lt;/td&gt;
&lt;td&gt;Google → Linux Foundation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Latest&lt;/td&gt;
&lt;td&gt;Spec revision 2025-11-25&lt;/td&gt;
&lt;td&gt;v1.0.0 (March 2026)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One-liner&lt;/td&gt;
&lt;td&gt;"Give it a tool"&lt;/td&gt;
&lt;td&gt;"Hand it a job"&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Which one? A decision flow
&lt;/h3&gt;

&lt;p&gt;When in doubt, ask first whether the thing you are connecting to is a "tool" or an "agent."&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F09-decision-flow.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fai-agent-protocols-mcp-a2a-2026%2Fdiagrams%2F09-decision-flow.png" alt="Decision flow: tool means MCP, autonomous agent means A2A" width="800" height="901"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  How it breaks when you pick wrong
&lt;/h3&gt;

&lt;p&gt;A design call is not just "pick the right one." Knowing how it breaks when you pick wrong makes you stronger.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Implementing a tool call over A2A&lt;/strong&gt;: you take on heavyweight machinery you do not need (fetching the card, task lifecycle management, async exchanges). It is overkill for a simple API call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forcing cross-company agent collaboration through MCP&lt;/strong&gt;: MCP assumes "the other side is a tool," so it cannot express peer multi-turn negotiation or the other side planning on its own. There are bridges that "wrap an A2A agent as an MCP tool and call it once," but treat that as the narrow case where it is fine to demote a peer to a tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopting ACP today&lt;/strong&gt;: the repo is archived and independent development has stopped. For the horizontal lane, lean on A2A.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Bonus: AGNTCY sits above all this
&lt;/h2&gt;

&lt;p&gt;MCP and A2A cover the broad strokes. But there is one more name worth dropping to finish the map: &lt;strong&gt;AGNTCY&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;AGNTCY is an initiative led by Cisco and others, donated to the Linux Foundation in July 2025, aiming at an "Internet of Agents." It is not a single protocol but a layer of infrastructure that sits higher up.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Discovery&lt;/strong&gt;: a way to find agents, DNS-style.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity&lt;/strong&gt;: cryptographically verifiable agent IDs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Messaging (SLIM)&lt;/strong&gt;: a low-latency, secure transport between agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: monitoring multi-agent workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It is positioned to &lt;strong&gt;interoperate with A2A and MCP rather than compete with them&lt;/strong&gt;. Think of MCP as "the socket to tools," A2A as "the socket between agents," and AGNTCY as "the city infrastructure that finds, connects, and monitors them all." You rarely touch it directly in day-to-day work, but knowing this layer exists gives you a clearer view of the whole ecosystem.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrap-up: a map makes it less scary
&lt;/h2&gt;

&lt;p&gt;This ran long, so let me close back on the first map.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An agent reaches out in only &lt;strong&gt;two directions: vertical (tools) and horizontal (agents)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vertical = MCP&lt;/strong&gt; (Anthropic). The socket that gives an LLM tools. Collapses N×M wiring. Internals are host/client/server and tools/resources/prompts. Lately its weight has shifted toward authn and authz.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Horizontal = A2A&lt;/strong&gt; (Google → Linux Foundation, v1.0.0 in March 2026). The socket for handing work to an agent. Read the Agent Card, run the Task. Collapses N×N wiring.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ACP&lt;/strong&gt; was a second standard for the horizontal lane, but it merged into A2A in August 2025 and its repo is archived. In 2026 treat it as history; do not adopt it for new work.&lt;/li&gt;
&lt;li&gt;In one system MCP and A2A &lt;strong&gt;coexist&lt;/strong&gt;: A2A for the sideways collaboration, MCP for the downward tool use. That is exactly why they are complementary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "pick one of three" framing written last year got re-sorted, in 2026, into "two pillars plus one that flowed in." Standards converge like this, splitting and consolidating, until they settle onto a single socket. The way USB-C did.&lt;/p&gt;

&lt;p&gt;Next time you actually write an MCP server or an A2A agent, start by asking yourself: "am I drawing a vertical line right now, or a horizontal one?" That alone tells you, without hesitation, which protocol's docs to open.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>mcp</category>
      <category>a2a</category>
    </item>
    <item>
      <title>The Day client_id Becomes a URL: Client ID Metadata Documents vs Dynamic Client Registration (DCR)</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Fri, 26 Jun 2026 12:08:30 +0000</pubDate>
      <link>https://dev.to/kanywst/the-day-clientid-becomes-a-url-client-id-metadata-documents-vs-dynamic-client-registration-dcr-dhi</link>
      <guid>https://dev.to/kanywst/the-day-clientid-becomes-a-url-client-id-metadata-documents-vs-dynamic-client-registration-dcr-dhi</guid>
      <description>&lt;h2&gt;
  
  
  The "who is this client?" problem
&lt;/h2&gt;

&lt;p&gt;In OAuth, the part that bites you is often not user authentication but &lt;strong&gt;client authentication&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An OAuth authorization flow has three parties: the &lt;strong&gt;user&lt;/strong&gt; who owns the resource, the &lt;strong&gt;authorization server (AS)&lt;/strong&gt; that guards it, and the &lt;strong&gt;client&lt;/strong&gt; (the app) that wants to act on the user's behalf. The user proves who they are at a login screen. The client cannot do that on its own. Unless the AS already knows something about the client, it has no way to check whether the app sending this request is really the app it claims to be.&lt;/p&gt;

&lt;p&gt;That is why OAuth has always had a &lt;strong&gt;client registration&lt;/strong&gt; step. The app developer registers in the AS's dashboard ("this is my app, here is where it redirects") and gets back an identifier called a &lt;code&gt;client_id&lt;/code&gt;. The client attaches that &lt;code&gt;client_id&lt;/code&gt; to its authorization requests, and the AS recognizes "ah, that registered app."&lt;/p&gt;

&lt;p&gt;The problem is that &lt;strong&gt;the world where pre-registration is even possible is shrinking&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bluesky / AT Protocol&lt;/strong&gt;: a user's data lives on one of countless servers (PDSes) scattered across the network. A client app cannot know in advance which server it will talk to, so registering with all of them is out of the question.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt;: an AI agent connects to arbitrary MCP servers. There are countless servers and the set keeps growing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fediverse (Mastodon and friends)&lt;/strong&gt;: thousands of self-hosted instances, any of which might be the counterparty.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In these "open meshes with no single AS," the classic pre-registration model falls apart. Two mechanisms emerged in response.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;DCR (Dynamic Client Registration, RFC 7591)&lt;/strong&gt;: the client registers itself with the AS at runtime. A record gets written into the AS's database.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CIMD (Client ID Metadata Document)&lt;/strong&gt;: the &lt;code&gt;client_id&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; an &lt;strong&gt;HTTPS URL&lt;/strong&gt;. The AS goes and reads that URL. Nothing is written on the AS side.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This article lays the two out so you can read top to bottom and follow the reasoning. We start from the basics of OAuth client registration, walk through how DCR works and where it hurts, and then see what CIMD's "URL = client_id" idea actually solves.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F01-cimd-vs-dcr-overview.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F01-cimd-vs-dcr-overview.png" alt="How the AS decides who a client is: DCR writes server state, CIMD reads client state" width="800" height="518"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Background: what client_id and redirect_uri are for
&lt;/h2&gt;

&lt;p&gt;Before comparing CIMD and DCR, it helps to be clear on why &lt;code&gt;client_id&lt;/code&gt; exists at all. If that is fuzzy, the rest of the comparison goes fuzzy too.&lt;/p&gt;

&lt;p&gt;In the OAuth 2.0 authorization code flow, the browser sends a request like this to the AS's authorization endpoint.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /authorize?response_type=code
  &amp;amp;client_id=s6BhdRkqt3
  &amp;amp;redirect_uri=https://app.example.com/callback
  &amp;amp;scope=read
  &amp;amp;state=xyz HTTP/1.1
Host: as.example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things matter here.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;client_id&lt;/code&gt;&lt;/strong&gt;: the identifier for which client this is. The AS uses it to look up the registration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;redirect_uri&lt;/code&gt;&lt;/strong&gt;: the URL the authorization code is returned to. The AS always checks this against &lt;strong&gt;the URL registered ahead of time, requiring an exact match&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;redirect_uri&lt;/code&gt; check is decisive because if an attacker could swap it freely, they could have the issued authorization code delivered to their own server. So &lt;strong&gt;"how does the AS learn the legitimate redirect_uri tied to a &lt;code&gt;client_id&lt;/code&gt;"&lt;/strong&gt; is the real job of client registration.&lt;/p&gt;

&lt;p&gt;DCR and CIMD are both answers to that same question, "how do you establish the mapping from &lt;code&gt;client_id&lt;/code&gt; to the legitimate redirect_uri (and the rest of the metadata)," approached from different angles.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F02-client-id-resolution.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F02-client-id-resolution.png" alt="client_id resolves to metadata, and the AS exact-matches the request redirect_uri against it" width="800" height="958"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Read "DCR or CIMD" as a difference in &lt;strong&gt;where the &lt;code&gt;client_id&lt;/code&gt; to metadata arrow is stored, and who supplies it&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  The classic approach: Dynamic Client Registration (DCR, RFC 7591)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How it works
&lt;/h3&gt;

&lt;p&gt;DCR is standardized in &lt;a href="https://www.rfc-editor.org/rfc/rfc7591" rel="noopener noreferrer"&gt;RFC 7591&lt;/a&gt;. The idea is simple: &lt;strong&gt;take the registration a human used to do in a dashboard and automate it over an API&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The client POSTs JSON to the AS's &lt;strong&gt;registration endpoint&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;POST /register HTTP/1.1
Content-Type: application/json
Host: as.example.com

{
  "redirect_uris": ["https://app.example.org/callback"],
  "client_name": "My Example Client",
  "token_endpoint_auth_method": "client_secret_basic",
  "logo_uri": "https://app.example.org/logo.png",
  "jwks_uri": "https://app.example.org/keys.jwks"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AS takes this, &lt;strong&gt;creates a new registration record in its own database&lt;/strong&gt;, and hands back a &lt;code&gt;client_id&lt;/code&gt; (and, for confidential clients, a &lt;code&gt;client_secret&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;HTTP/1.1 201 Created
Content-Type: application/json

{
  "client_id": "s6BhdRkqt3",
  "client_secret": "cf136dc3c1fc93f31185e5885805d",
  "client_id_issued_at": 2893256800,
  "client_secret_expires_at": 2893276800,
  "redirect_uris": ["https://app.example.org/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "token_endpoint_auth_method": "client_secret_basic"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;From here the client uses its issued &lt;code&gt;client_id&lt;/code&gt; to run authorization flows as usual. Updating or deleting the registration is handled by the sibling spec &lt;a href="https://www.rfc-editor.org/rfc/rfc7592.html" rel="noopener noreferrer"&gt;RFC 7592&lt;/a&gt; (the management protocol). RFC 7592 also returns a &lt;code&gt;registration_access_token&lt;/code&gt; and a &lt;code&gt;registration_client_uri&lt;/code&gt; at registration time, and the client uses those two to GET / PUT / DELETE its own record.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F03-dcr-sequence.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F03-dcr-sequence.png" alt="DCR sequence: register writes a DB record, later the AS looks it up by client_id" width="800" height="535"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Where DCR hurts
&lt;/h3&gt;

&lt;p&gt;DCR works well when a client can hold a one-to-one relationship with an AS. The trouble starts when you drop it into one of the open meshes from the intro.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;State explosion&lt;/strong&gt;: one record per client instance, per counterparty server. The same app on Windows and macOS becomes two records; a hundred thousand users becomes a hundred thousand records. The AS database grows without bound.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Garbage registrations accumulate&lt;/strong&gt;: records used once, or belonging to apps that no longer exist, never get garbage-collected. Revocation is mostly manual.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Registration endpoint abuse and DoS&lt;/strong&gt;: open registration (anyone can register) lets an attacker mint records en masse. Preventing that needs an &lt;strong&gt;initial access token&lt;/strong&gt; or a &lt;strong&gt;software statement&lt;/strong&gt; (&lt;code&gt;software_statement&lt;/code&gt;, a signed JWT) to gate registration, which reintroduces the "hand something out in advance" coordination and takes away much of the appeal of being dynamic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Weak client-identity assurance&lt;/strong&gt;: DCR itself does not verify that the app is who it says it is. Both &lt;code&gt;client_name&lt;/code&gt; and &lt;code&gt;logo_uri&lt;/code&gt; are self-asserted. That is fertile ground for consent phishing on the authorization screen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, DCR assumes you &lt;strong&gt;write state on the server side&lt;/strong&gt;, and the cost and lifecycle of that state break down in an open world.&lt;/p&gt;




&lt;h2&gt;
  
  
  A different idea: Client ID Metadata Document (CIMD)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The core idea: make client_id a URL
&lt;/h3&gt;

&lt;p&gt;CIMD's idea fits in one line.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Make the &lt;code&gt;client_id&lt;/code&gt; the HTTPS URL where the metadata JSON lives.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So it looks like this.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client_id = https://app.example.com/oauth-client-metadata.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The client does not pre-register. Instead it puts its metadata (the same &lt;code&gt;redirect_uris&lt;/code&gt; and so on it used to POST under RFC 7591) into a JSON file and &lt;strong&gt;hosts it at a URL it controls&lt;/strong&gt;. In the authorization request it passes that whole URL as the &lt;code&gt;client_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When the AS receives a &lt;code&gt;client_id&lt;/code&gt; it has never seen, it &lt;strong&gt;goes and reads that URL&lt;/strong&gt; to obtain the metadata. Nothing is written on the AS side. All the state lives in the document the client publishes.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F04-cimd-fetch-overview.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F04-cimd-fetch-overview.png" alt="CIMD overview: the AS GETs the client-hosted JSON, matches redirect_uri, issues a code" width="800" height="1047"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A database write became "GET a URL once." That is the whole shape of CIMD.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where this idea came from
&lt;/h3&gt;

&lt;p&gt;"Use a URL as an identifier" is not new. The decentralized web has leaned on it for over a decade. The CIMD draft itself explicitly credits &lt;strong&gt;IndieAuth, Solid-OIDC, and OpenID Federation&lt;/strong&gt; as prior art.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;IndieAuth&lt;/strong&gt; (IndieWeb): has identified clients by URL for 10+ years. It first fetched the &lt;code&gt;client_id&lt;/code&gt; URL and read HTML microformats (&lt;code&gt;h-app&lt;/code&gt;), then moved to JSON metadata.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solid-OIDC&lt;/strong&gt; (Tim Berners-Lee's Solid project): puts a Client Identifier Document, in JSON-LD, at a client-controlled URL. This is the &lt;strong&gt;direct ancestor&lt;/strong&gt; of CIMD.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AT Protocol / Bluesky&lt;/strong&gt;: adopted URL-based &lt;code&gt;client_id&lt;/code&gt; in its OAuth profile in 2024, explicitly citing the draft (draft-parecki).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MCP (Model Context Protocol)&lt;/strong&gt;: in the 2025-11-25 spec, made &lt;strong&gt;CIMD the recommended default (&lt;code&gt;SHOULD&lt;/code&gt;)&lt;/strong&gt; and demoted DCR to &lt;code&gt;MAY&lt;/code&gt;. That is what accelerated the IETF discussion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The authors are &lt;strong&gt;Aaron Parecki (Okta)&lt;/strong&gt; and &lt;strong&gt;Emelia Smith&lt;/strong&gt;. Smith comes from the fediverse side, and the fediverse motivation ("an app connects to whatever self-hosted server it finds") is part of CIMD's background. Note that Mastodon itself still uses the DCR-style &lt;code&gt;POST /api/v1/apps&lt;/code&gt; and has not shipped CIMD.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F05-url-client-id-lineage.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F05-url-client-id-lineage.png" alt="Lineage of URL-as-client_id, from IndieAuth through Solid and Bluesky to MCP and the IETF draft" width="800" height="1458"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  CIMD mechanics in detail
&lt;/h2&gt;

&lt;p&gt;From here I follow the normative text of &lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/" rel="noopener noreferrer"&gt;draft-ietf-oauth-client-id-metadata-document&lt;/a&gt; (latest is -01, published March 2026 as of June 2026).&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Rules for the client_id URL
&lt;/h3&gt;

&lt;p&gt;The URL you use as &lt;code&gt;client_id&lt;/code&gt; has constraints (draft Section 3).&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Must use the &lt;code&gt;https&lt;/code&gt; scheme&lt;/strong&gt; (&lt;code&gt;http&lt;/code&gt; is not allowed).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Must contain a path component&lt;/strong&gt; (e.g. &lt;code&gt;/oauth-client-metadata.json&lt;/code&gt;). A host-only URL is not allowed.&lt;/li&gt;
&lt;li&gt;Must not contain single-dot or double-dot path segments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Must not contain a fragment&lt;/strong&gt; (&lt;code&gt;#...&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Must not contain a username or password (&lt;code&gt;user:pass@&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;A query string is discouraged but allowed. A port is allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Short, stable URLs are recommended.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The self-reference rule (the anti-impersonation linchpin)
&lt;/h3&gt;

&lt;p&gt;The document &lt;strong&gt;must contain a &lt;code&gt;client_id&lt;/code&gt; property whose value matches the document URL by simple string comparison&lt;/strong&gt; (Section 4.1). It must also be served with HTTP &lt;strong&gt;200 OK&lt;/strong&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;"client_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;"https://app.example.com/oauth-client-metadata.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;"..."&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This "the URL and the document's &lt;code&gt;client_id&lt;/code&gt; agree" is the backbone of CIMD's security. Why? Because &lt;strong&gt;only whoever controls a domain can put content on it&lt;/strong&gt;. So the only party that can claim a &lt;code&gt;client_id&lt;/code&gt; of &lt;code&gt;https://app.example.com/...&lt;/code&gt; is the party that actually controls &lt;code&gt;app.example.com&lt;/code&gt;. If an attacker spoofs &lt;code&gt;https://app.example.com/...&lt;/code&gt; as a &lt;code&gt;client_id&lt;/code&gt;, fetching that URL returns the real document, the contents do not match, and it gets rejected. &lt;strong&gt;Domain control is the proof of identity.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Fields you can put in the document
&lt;/h3&gt;

&lt;p&gt;The metadata vocabulary is &lt;strong&gt;the same as RFC 7591 (DCR) client metadata&lt;/strong&gt;: &lt;code&gt;redirect_uris&lt;/code&gt;, &lt;code&gt;scope&lt;/code&gt;, &lt;code&gt;grant_types&lt;/code&gt;, &lt;code&gt;response_types&lt;/code&gt;, &lt;code&gt;client_name&lt;/code&gt;, &lt;code&gt;client_uri&lt;/code&gt;, &lt;code&gt;logo_uri&lt;/code&gt;, &lt;code&gt;jwks&lt;/code&gt; / &lt;code&gt;jwks_uri&lt;/code&gt;, and so on. Think "take what you used to POST under DCR and publish it as a file."&lt;/p&gt;

&lt;p&gt;There are forbidden fields, though.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;client_secret&lt;/code&gt; and &lt;code&gt;client_secret_expires_at&lt;/code&gt; (the document is public, so you cannot put a secret in it).&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;token_endpoint_auth_method&lt;/code&gt; values &lt;code&gt;client_secret_post&lt;/code&gt; / &lt;code&gt;client_secret_basic&lt;/code&gt; / &lt;code&gt;client_secret_jwt&lt;/code&gt; (no authentication based on a shared symmetric secret).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. How the AS fetches
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The AS &lt;strong&gt;should (SHOULD)&lt;/strong&gt; fetch the URL indicated by the &lt;code&gt;client_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;It &lt;strong&gt;must not (MUST NOT)&lt;/strong&gt; automatically follow HTTP redirects while fetching. This closes off SSRF via a redirect to an internal IP (more below).&lt;/li&gt;
&lt;li&gt;It may cache (MAY). It should respect HTTP cache headers (SHOULD). It &lt;strong&gt;must not (MUST NOT)&lt;/strong&gt; cache error responses. The cache key is the URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  5. redirect_uri validation
&lt;/h3&gt;

&lt;p&gt;The AS must require redirect URIs to be registered (MUST), and must require the request's &lt;code&gt;redirect_uri&lt;/code&gt; to be &lt;strong&gt;an exact match of one of the &lt;code&gt;redirect_uris&lt;/code&gt; in the document&lt;/strong&gt; (MUST, Section 4.5). The AS may additionally constrain &lt;code&gt;redirect_uris&lt;/code&gt; to the domain of &lt;code&gt;client_id&lt;/code&gt; / &lt;code&gt;client_uri&lt;/code&gt; (Section 6.1). The "exact redirect_uri match" from the background section is doing its work here too.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Public clients, and going confidential
&lt;/h3&gt;

&lt;p&gt;Because the document is public, a CIMD client is fundamentally a &lt;strong&gt;public client&lt;/strong&gt;. It cannot hold a &lt;code&gt;client_secret&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you still want to behave as a confidential client, you &lt;strong&gt;publish a public key in the metadata (&lt;code&gt;jwks&lt;/code&gt; / &lt;code&gt;jwks_uri&lt;/code&gt;) and authenticate with &lt;code&gt;private_key_jwt&lt;/code&gt; (asymmetric)&lt;/strong&gt; (Section 6.2). The private key never appears in the public document, so it works out safely. Bluesky's confidential clients use exactly this (ES256 + &lt;code&gt;private_key_jwt&lt;/code&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F06-cimd-auth-sequence.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F06-cimd-auth-sequence.png" alt="CIMD authorization sequence: the AS fetches the document, validates it, then runs consent" width="800" height="727"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  DCR vs CIMD, head to head
&lt;/h2&gt;

&lt;p&gt;Here is the mechanics so far in a table. The thing to watch is the symmetry between &lt;strong&gt;"where the state lives"&lt;/strong&gt; and &lt;strong&gt;"what infrastructure burden it puts on the AS."&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;Axis&lt;/th&gt;
&lt;th&gt;CIMD&lt;/th&gt;
&lt;th&gt;DCR (RFC 7591 / 7592)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Where state lives&lt;/td&gt;
&lt;td&gt;JSON at a client-hosted URL&lt;/td&gt;
&lt;td&gt;A row in the AS database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What &lt;code&gt;client_id&lt;/code&gt; is&lt;/td&gt;
&lt;td&gt;The HTTPS URL itself&lt;/td&gt;
&lt;td&gt;An opaque string minted by the AS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Basis of trust&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Domain control&lt;/strong&gt; + exact redirect_uri match&lt;/td&gt;
&lt;td&gt;An AS-issued record (optionally a software statement / initial access token)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AS infra burden&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Outbound HTTP&lt;/strong&gt; (fetch + cache)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Write storage&lt;/strong&gt; + lifecycle management&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets&lt;/td&gt;
&lt;td&gt;None by default (public client). Confidential via &lt;code&gt;private_key_jwt&lt;/code&gt; + published JWKS&lt;/td&gt;
&lt;td&gt;Can issue a &lt;code&gt;client_secret&lt;/code&gt; (confidential client)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revocation&lt;/td&gt;
&lt;td&gt;Take down / change the document + cache expiry&lt;/td&gt;
&lt;td&gt;DELETE the registration record (RFC 7592)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rotation&lt;/td&gt;
&lt;td&gt;Edit the document (swap the JWKS, etc.)&lt;/td&gt;
&lt;td&gt;AS-driven secret / token rotation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portability&lt;/td&gt;
&lt;td&gt;One document serves every AS and every instance&lt;/td&gt;
&lt;td&gt;One registration per (instance x AS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main failure mode&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Availability coupling&lt;/strong&gt; (document host down means no auth) + an &lt;strong&gt;SSRF&lt;/strong&gt; surface&lt;/td&gt;
&lt;td&gt;DB bloat, garbage records, endpoint DoS&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The difference in one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;CIMD trades "the AS needs write storage" for "the AS needs outbound HTTP."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;DCR accumulates state on the server. CIMD puts state on the client and the AS reads it. Neither is strictly better; it is a choice of &lt;strong&gt;which cost you can pay, or want to pay.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F07-where-state-lives.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F07-where-state-lives.png" alt="Where state lives: DCR piles records in the AS DB, CIMD has many ASes read one client document" width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Security: SSRF takes center stage
&lt;/h2&gt;

&lt;p&gt;CIMD's biggest risk is &lt;strong&gt;SSRF (Server-Side Request Forgery)&lt;/strong&gt;, because &lt;strong&gt;the AS sends an outbound request to a URL the attacker chose&lt;/strong&gt;. The &lt;code&gt;client_id&lt;/code&gt; is fully attacker-controlled. Point it at an internal metadata endpoint like &lt;code&gt;client_id=http://169.254.169.254/...&lt;/code&gt; and the AS will fetch it. That is the textbook SSRF setup, verbatim.&lt;/p&gt;

&lt;p&gt;The draft pins down SSRF defenses quite strictly (Section 6.5 and others). On the way from the individual draft to the WG draft, many of these were upgraded from SHOULD to MUST.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Block special-use IPs (MUST)&lt;/strong&gt;: the AS must validate that the &lt;code&gt;client_id&lt;/code&gt; URL does not resolve to a special-use address from &lt;a href="https://www.rfc-editor.org/rfc/rfc6890" rel="noopener noreferrer"&gt;RFC 6890&lt;/a&gt; (private IPs, loopback, link-local, etc.) (Section 6.5). The only exception is when the AS itself runs on loopback and the resolved address matches that same loopback interface. Note the strength split: declining to fetch &lt;em&gt;other&lt;/em&gt; URLs written inside the document is SHOULD NOT, not MUST.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Do not follow redirects (MUST NOT)&lt;/strong&gt;: no automatic following of HTTP redirects while fetching. This blocks SSRF / DNS rebinding via a redirect like &lt;code&gt;https://evil.example/&lt;/code&gt; to &lt;code&gt;http://169.254.169.254/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;https&lt;/code&gt; only&lt;/strong&gt;: non-https schemes raise SSRF risk, which is one more reason for the https-only rule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Response size limit (DoS defense)&lt;/strong&gt;: against returning a huge document to exhaust AS memory, set an upper bound (recommended around 5 KB).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content-Type / status checks&lt;/strong&gt;: confirm 200 OK and JSON. AT Protocol requires a "hardened HTTP client."&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F08-ssrf-validation.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F08-ssrf-validation.png" alt="SSRF validation chain: scheme, IP range, redirect, status/size, then client_id self-match" width="800" height="1022"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There are other concerns beyond SSRF.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Mutable metadata and impersonation (Section 6.3)&lt;/strong&gt;: the document is under the client's control and can change at any time. If &lt;code&gt;redirect_uris&lt;/code&gt;, &lt;code&gt;jwks&lt;/code&gt;, or &lt;code&gt;scope&lt;/code&gt; change, the AS should consider revoking tokens or consent. And if &lt;strong&gt;the URL passes to a new owner (a recycled domain), that new owner inherits the client identity wholesale&lt;/strong&gt;. That is an operational landmine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consent-screen phishing (Section 6.4)&lt;/strong&gt;: &lt;code&gt;client_name&lt;/code&gt; and &lt;code&gt;logo_uri&lt;/code&gt; are attacker-settable, unverified values. So the AS should &lt;strong&gt;also display the hostname of the &lt;code&gt;client_id&lt;/code&gt;&lt;/strong&gt; on the consent screen (SHOULD). AT Protocol likewise warns plainly that rich metadata is unverified and that you should only render it for allowlisted, trusted clients.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For contrast, the DCR-side security concerns:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Protect the registration endpoint&lt;/strong&gt;: open registration invites DoS and garbage records. Gate it with an initial access token or a software statement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;registration_access_token&lt;/code&gt; is high value&lt;/strong&gt;: it can read, update, and delete the registration record. Leak it and an attacker can rewrite &lt;code&gt;redirect_uris&lt;/code&gt;. Treat it as a secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;client_secret&lt;/code&gt; leakage&lt;/strong&gt;: risk at rest and in transit. CIMD sidesteps this structurally because (as a public client) it holds no secret at all.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Concrete examples
&lt;/h2&gt;

&lt;h3&gt;
  
  
  CIMD: an AT Protocol (Bluesky) confidential client
&lt;/h3&gt;

&lt;p&gt;The JSON you host at &lt;code&gt;https://app.example.com/oauth-client-metadata.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;"client_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;"https://app.example.com/oauth-client-metadata.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;"application_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;"web"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"client_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;"Example Client"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"client_uri"&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://app.example.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"redirect_uris"&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;"https://app.example.com/callback"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"response_types"&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;"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;"grant_types"&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;"authorization_code"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"refresh_token"&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="s2"&gt;"atproto transition:generic"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"private_key_jwt"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"token_endpoint_auth_signing_alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES256"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dpop_bound_access_tokens"&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;"jwks"&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;"keys"&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;"kty"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EC"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"crv"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"P-256"&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"&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;"y"&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;"kid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"key-1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ES256"&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;A public client sets &lt;code&gt;token_endpoint_auth_method&lt;/code&gt; to &lt;code&gt;"none"&lt;/code&gt; and drops &lt;code&gt;jwks&lt;/code&gt;. AT Protocol additionally requires &lt;strong&gt;DPoP&lt;/strong&gt; and &lt;strong&gt;PAR&lt;/strong&gt;. DPoP binds the access token to the client's key so a stolen token cannot be replayed (&lt;a href="https://www.rfc-editor.org/rfc/rfc9449" rel="noopener noreferrer"&gt;RFC 9449&lt;/a&gt;); PAR pushes the authorization request parameters to the AS over a back channel in advance, preventing front-channel URL tampering (&lt;a href="https://www.rfc-editor.org/rfc/rfc9126" rel="noopener noreferrer"&gt;RFC 9126&lt;/a&gt;). Neither is required by CIMD; they are extra safety Bluesky layers on top.&lt;/p&gt;

&lt;h3&gt;
  
  
  DCR: the RFC 7591 request and response
&lt;/h3&gt;

&lt;p&gt;The DCR shape is what we saw earlier: POST, receive a &lt;code&gt;client_id&lt;/code&gt; / &lt;code&gt;client_secret&lt;/code&gt;. It helps to see the same metadata vocabulary (&lt;code&gt;redirect_uris&lt;/code&gt;, &lt;code&gt;grant_types&lt;/code&gt;, &lt;code&gt;jwks_uri&lt;/code&gt;, ...) showing up as "JSON you publish" in CIMD and as "the body you POST and the record you get back" in DCR.&lt;/p&gt;

&lt;h3&gt;
  
  
  How it differs from IndieAuth
&lt;/h3&gt;

&lt;p&gt;Even within the "URL as client_id, fetch it" family, IndieAuth differs in the details.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;IndieAuth in its older mode reads HTML microformats (the newer mode uses RFC 7591 JSON). CIMD uses the RFC 7591 JSON vocabulary from the start.&lt;/li&gt;
&lt;li&gt;IndieAuth requires the stricter rule that &lt;strong&gt;&lt;code&gt;client_uri&lt;/code&gt; be a prefix of &lt;code&gt;client_id&lt;/code&gt;&lt;/strong&gt;. CIMD uses the simpler "the URL and the document's &lt;code&gt;client_id&lt;/code&gt; match exactly" rule, plus an optional domain constraint.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  IETF status, and how the two end up splitting the work
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Status
&lt;/h3&gt;

&lt;p&gt;CIMD is a &lt;strong&gt;WG-adopted Internet-Draft&lt;/strong&gt;; the latest is -01 (2026-03-02). The -01 document header declares &lt;strong&gt;Intended Status: Standards Track&lt;/strong&gt;, so it is aiming for the standards track (though it is not yet published as an RFC). It is not folded into the OAuth 2.1 core spec; it is moving as a &lt;strong&gt;separate extension draft&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open questions
&lt;/h3&gt;

&lt;p&gt;Points actively discussed on the draft's mailing list and issue tracker.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SSRF&lt;/strong&gt; has been the biggest driver of revisions: special-use IPs moved to MUST, redirect-following to MUST NOT, the 5 KB limit was added. Feature requests like "allow non-public &lt;code&gt;client_id&lt;/code&gt;s" were rejected as too SSRF-risky.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Identity for non-web clients&lt;/strong&gt;: domain binding works for web apps (a domain backed by TLS / a CA) but not for desktop / CLI / native clients that cannot host a stable URL and use localhost or custom-scheme redirects. The critics' consensus is that CIMD solves &lt;strong&gt;registration&lt;/strong&gt; but not fully the &lt;strong&gt;client-trust / identity&lt;/strong&gt; problem. Complements such as signed software statements and &lt;strong&gt;platform attestation (Attestation-Based Client Authentication)&lt;/strong&gt; are starting to be referenced as things that work with both CIMD and DCR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mutable, client-controlled metadata&lt;/strong&gt; and &lt;strong&gt;ecosystem-wide upgrade friction&lt;/strong&gt; (Emelia Smith's point that you can hardly update a CIMD until every AS in the ecosystem has upgraded).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  MCP makes the split clearest
&lt;/h3&gt;

&lt;p&gt;In the 2025-11-25 spec, MCP made CIMD the recommended default, but &lt;strong&gt;it deprioritized DCR rather than deprecating it&lt;/strong&gt;. The reasons DCR survives are exactly the answer to how the two split the work.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clients that cannot host a public document (desktop / CLI / IDE / internal agents) cannot do CIMD, so they need DCR.&lt;/li&gt;
&lt;li&gt;DCR is an &lt;strong&gt;enforcement point&lt;/strong&gt; where an organization can apply policy at registration time (dynamic but not open: managed onboarding).&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F09-cimd-or-dcr-decision.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fclient-id-metadata-document-vs-dcr%2Fdiagrams%2F09-cimd-or-dcr-decision.png" alt="Decision: can the client host a stable URL, and are the ASes an open mesh, picks CIMD vs DCR" width="800" height="637"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;OAuth client registration ultimately comes down to one problem: &lt;strong&gt;how do you establish the mapping from &lt;code&gt;client_id&lt;/code&gt; to a legitimate redirect_uri / metadata.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DCR (RFC 7591)&lt;/strong&gt; establishes it by &lt;strong&gt;writing into the AS database&lt;/strong&gt;. It works for one-to-one relationships, but in an open mesh it breaks down through state explosion, garbage records, and endpoint abuse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CIMD&lt;/strong&gt; makes the &lt;code&gt;client_id&lt;/code&gt; an &lt;strong&gt;HTTPS URL&lt;/strong&gt; and has the AS &lt;strong&gt;go read it&lt;/strong&gt;. State lives in the document the client publishes, and domain control becomes the proof of identity.&lt;/li&gt;
&lt;li&gt;The two are a symmetric trade-off: &lt;strong&gt;"the AS needs write storage" versus "the AS needs outbound HTTP."&lt;/strong&gt; CIMD's price is SSRF and availability coupling, and the draft pins down SSRF defenses at the MUST level.&lt;/li&gt;
&lt;li&gt;It grew up in the decentralized-social world of Bluesky / Solid / IndieAuth, and &lt;strong&gt;MCP making it the default pushed it into the mainstream&lt;/strong&gt;. But DCR stays for clients that cannot host a URL, and the two split the work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The &lt;code&gt;client_id&lt;/code&gt; stops being a string and becomes a URL. That one shift in framing is what makes client authentication work in "the world where you cannot pre-register." If your product is the side that connects out into an open mesh, CIMD is going to be hard to ignore before long.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/" rel="noopener noreferrer"&gt;draft-ietf-oauth-client-id-metadata-document (IETF datatracker)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7591" rel="noopener noreferrer"&gt;RFC 7591: OAuth 2.0 Dynamic Client Registration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.rfc-editor.org/rfc/rfc7592.html" rel="noopener noreferrer"&gt;RFC 7592: Dynamic Client Registration Management Protocol&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://atproto.com/specs/oauth" rel="noopener noreferrer"&gt;AT Protocol OAuth spec&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://solid.github.io/solid-oidc/" rel="noopener noreferrer"&gt;Solid-OIDC spec&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://indieauth.spec.indieweb.org/" rel="noopener noreferrer"&gt;IndieAuth spec&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" rel="noopener noreferrer"&gt;MCP Authorization spec (2025-11-25)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://aaronparecki.com/2025/10/08/4/cimd" rel="noopener noreferrer"&gt;Aaron Parecki: Client ID Metadata Document Adopted by the OAuth Working Group&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>oauth</category>
      <category>security</category>
      <category>identity</category>
      <category>mcp</category>
    </item>
    <item>
      <title>AgentAuth Deep Dive: Reading the Self-Authenticating UUID for AI Agents from the Source</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Thu, 25 Jun 2026 13:44:37 +0000</pubDate>
      <link>https://dev.to/kanywst/agentauth-deep-dive-reading-the-self-authenticating-uuid-for-ai-agents-from-the-source-44eh</link>
      <guid>https://dev.to/kanywst/agentauth-deep-dive-reading-the-self-authenticating-uuid-for-ai-agents-from-the-source-44eh</guid>
      <description>&lt;h2&gt;
  
  
  The trigger: showing an agent a login screen makes no sense
&lt;/h2&gt;

&lt;p&gt;Every time I write an MCP (Model Context Protocol) server, the same problem stops me. The agent that just sent this request: who is it, and how am I supposed to tell?&lt;/p&gt;

&lt;p&gt;For a human-facing web service the answer is settled. Show a login form, take an email and password, hand back a session cookie. Or OAuth: redirect the browser to an IdP, show a consent screen, come back holding a token. Every one of these assumes a human is sitting in front of a screen.&lt;/p&gt;

&lt;p&gt;An AI agent has no human sitting in front of anything. It fires tools at 3am on its own, and one user might run ten agents in parallel. "Click the consent screen" and "remember the password" both fall apart. Hand every agent the same API key instead, and now you cannot tell who did what.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/agentauthco/agentauth" rel="noopener noreferrer"&gt;AgentAuth&lt;/a&gt; answers this with a fairly extreme move.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;All you need is one UUID. No login, no sessions, no extra infra.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My first reaction was "authenticating with a UUID? that is just an ID, isn't it?" But once I read the source, this is not "just an ID." It is an ID that can prove its own correctness. This article reads that mechanism down to the code.&lt;/p&gt;

&lt;p&gt;Here is the whole picture in one diagram first. I will take it apart piece by piece afterward.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F01-self-authenticating-overview.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F01-self-authenticating-overview.png" alt="Self-authenticating UUID overview: Token derives Address and ID, and a per-request signature is verified by the server" width="800" height="987"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Only the &lt;code&gt;Token&lt;/code&gt; is secret. The &lt;code&gt;Address&lt;/code&gt; and &lt;code&gt;ID&lt;/code&gt; are derived from it deterministically. By attaching a &lt;code&gt;Signature&lt;/code&gt; to every request, the server can confirm that the real holder of that ID actually sent it. That is what "self-authenticating UUID" means.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background: identity and authentication are two different things
&lt;/h2&gt;

&lt;p&gt;Before the mechanism, let me pin down one pair of terms. If this stays fuzzy, the interesting part of AgentAuth never lands.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity&lt;/strong&gt;: "who are you." Like a user ID or an employee number. It just has to distinguish people&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt;: "are you really you." Like a password or a fingerprint. It stops impersonation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In an ordinary system these two are built from separate machinery. Identity is "a user ID in the DB," authentication is "a password check." That is exactly why you need two steps: registration (create the identity) and login (authenticate).&lt;/p&gt;

&lt;p&gt;AgentAuth's claim is &lt;strong&gt;"you can fold these two into a single UUID."&lt;/strong&gt; Look at the UUID and you know who it is (identity), and if you attach a signature that only the holder of that UUID could produce, you also get authentication. Registration and login both disappear.&lt;/p&gt;

&lt;p&gt;Why is that possible? The key is public-key cryptography. It comes up over and over from here on, so let me get the three minimum properties out of the way first. Skip ahead if you know them.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Keys come in pairs&lt;/strong&gt;: a "private key" and a "public key" are always born together. You hold the private key alone; the public key can be shown to anyone&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One-way&lt;/strong&gt;: computing the public key from the private key is easy. The reverse (recovering the private key from the public key) is effectively impossible. Picture rolling down a hill versus climbing back up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only the private key can sign, anyone can verify with the public key&lt;/strong&gt;: attach a "signature" to some data with the private key, and whoever holds the matching public key can confirm "the holder of this private key made this signature over this data." Someone without the private key cannot forge a valid signature&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;AgentAuth maps these three properties straight onto "identity (derive it one-way)" and "authentication (signature)." Let me walk through them in order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Core concept: Token to Address to ID, a one-way slope
&lt;/h2&gt;

&lt;p&gt;AgentAuth has three values. They are easy to confuse, so let me pin their roles and "is it secret" in a table first.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;th&gt;Secret?&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AgentAuth Token&lt;/td&gt;
&lt;td&gt;&lt;code&gt;aa-2337b9fa...bcb602&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;secp256k1 private key (32 bytes)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The source of everything. Password-equivalent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Address&lt;/td&gt;
&lt;td&gt;&lt;code&gt;0x99063225...58285ce&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Public address derived from the private key&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Public-key material for verifying signatures&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AgentAuth ID&lt;/td&gt;
&lt;td&gt;&lt;code&gt;811ec2bf-b653-...dad7&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stable UUID built from the Address&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Identity usable as a DB primary key&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The point is that these three are derived "one-way" in the order &lt;strong&gt;Token to Address to ID&lt;/strong&gt;. Going down the slope is cheap to compute, but going back up (recovering the Token from the ID) is cryptographically impossible.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F02-derivation-chain.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F02-derivation-chain.png" alt="Derivation chain: Token to public key to Address to AgentAuth ID, with the Token never leaving the client" width="800" height="1339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Notice this: &lt;strong&gt;only the Address and ID reach the server, the Token never leaves once.&lt;/strong&gt; You keep the private key in your own hand and show the other side only the public values derived from it. This pays off when it combines with the "signature" later.&lt;/p&gt;

&lt;p&gt;Let me walk the three steps one at a time through the actual code (&lt;a href="https://github.com/agentauthco/agentauth/tree/main/packages/agentauth-core" rel="noopener noreferrer"&gt;&lt;code&gt;@agentauth/core&lt;/code&gt;&lt;/a&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Steps 1 and 2: derive the Address from the Token
&lt;/h3&gt;

&lt;p&gt;What &lt;code&gt;deriveAddress&lt;/code&gt; does is, in fact, &lt;strong&gt;exactly the same as Ethereum's address derivation.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;deriveAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Strip "aa-" or "0x" down to the 32-byte private key&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanPrivateKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePrivateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;privateKeyBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleanPrivateKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Uncompressed public key from the private key (65 bytes: 0x04 + X(32) + Y(32))&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publicKeyBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;secp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privateKeyBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Drop the leading 0x04 to get the 64-byte coordinate pair&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;publicKeyCoords&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;publicKeyBytes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. keccak256, then take the "last 20 bytes"&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addressBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keccak_256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;publicKeyCoords&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;bytesToHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addressBytes&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;Compute the public key with &lt;code&gt;secp256k1&lt;/code&gt; (an elliptic curve), hash it with &lt;code&gt;keccak256&lt;/code&gt;, take the last 20 bytes. The resulting &lt;code&gt;0x...&lt;/code&gt; is the same format as an Ethereum wallet address.&lt;/p&gt;

&lt;p&gt;One thing not to misread: &lt;strong&gt;there is not a single bit of blockchain involved.&lt;/strong&gt; No on-chain processing, no gas. It just reuses, at the crypto-library level, the well-aged recipe Ethereum has run for years to "derive an address from a private key." The implementation rides on the audited &lt;a href="https://github.com/paulmillr/noble-secp256k1" rel="noopener noreferrer"&gt;&lt;code&gt;@noble/secp256k1&lt;/code&gt;&lt;/a&gt; and &lt;code&gt;@noble/hashes&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: build a stable UUID from the Address
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;generateId&lt;/code&gt; is very short.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;AGENTAUTH_NAMESPACE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2f5a5c48-c283-4231-8975-9271fe11e86c&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;generateId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&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;uuidv5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;AGENTAUTH_NAMESPACE&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;UUIDv5 builds a UUID deterministically by hashing "namespace + name" with SHA-1 (unlike the random UUIDv4, the same input always yields the same output). Here the name passed in is the Address.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;AGENTAUTH_NAMESPACE&lt;/code&gt; is hardcoded as a &lt;strong&gt;value that must never change.&lt;/strong&gt; The source comment spells it out.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;DO NOT CHANGE THIS UUID - EVER. (...) If you change this UUID, you will break all existing AgentAuth IDs.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Change the namespace and every agent's ID in the world becomes a different value. So this constant effectively defines "the AgentAuth standard itself." Hold the same Token and the same AgentAuth ID comes out, no matter who computes it or when. That determinism is the foundation for "a stable identity without a login."&lt;/p&gt;

&lt;h3&gt;
  
  
  Why this removes registration
&lt;/h3&gt;

&lt;p&gt;With that in hand, the reason registration disappears becomes clear.&lt;/p&gt;

&lt;p&gt;An ordinary system needs a registration step because "the server assigns a user ID and records it in the DB." In AgentAuth the ID is derived mathematically from the private key, so &lt;strong&gt;the client can settle its own ID without asking the server.&lt;/strong&gt; The server just &lt;code&gt;INSERT&lt;/code&gt;s into the DB the first time it sees an ID, with no prior "account creation" flow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The heart of it: authenticating with a per-request signature
&lt;/h2&gt;

&lt;p&gt;So far we have the identity (the ID). But the ID is public information. If someone knows your AgentAuth ID, can't they just claim it and fire off requests?&lt;/p&gt;

&lt;p&gt;The trick that stops that is the &lt;strong&gt;signature.&lt;/strong&gt; It attaches to the request a piece of proof that only the true holder of the AgentAuth ID, the one holding the Token, can produce.&lt;/p&gt;

&lt;h3&gt;
  
  
  Making the signature (client side)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;signPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageString&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keccak_256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageString&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleanPrivateKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePrivateKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;privateKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;privateKeyBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;hexToBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cleanPrivateKey&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;secp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageHash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;privateKeyBytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// EVM-standard signature format: 0x + r(32) + s(32) + recovery(1)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signatureHex&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toCompactHex&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recovery&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`0x&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;signatureHex&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&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;Hash the payload (JSON) with keccak256 and sign it with the private key. The output is &lt;code&gt;0x&lt;/code&gt; plus a 65-byte signature. As noted below, the payload always carries a &lt;strong&gt;timestamp.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Verifying the signature (server side)
&lt;/h3&gt;

&lt;p&gt;The crux on the verify side is the "recover the public key from the signature" operation. Same idea as Ethereum's &lt;code&gt;ecrecover&lt;/code&gt;: from the signature and the message, back-compute "the address corresponding to the private key that could have produced this signature."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;object&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expectedAddress&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// ... pull r, s, recovery out of the signature ...&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;messageHash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keccak_256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// Recover the public key from the signature (ecrecover-equivalent)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;secp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Signature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nc"&gt;BigInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;addRecoveryBit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recovery&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recoveredPublicKey&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recoverPublicKey&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;messageHash&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Convert the recovered public key into an address&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addressBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;keccak_256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;recoveredPublicKey&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toRawBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;recoveredAddress&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;0x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;bytesToHex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addressBytes&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="c1"&gt;// Does the "claimed address" match the "address recovered from the signature"?&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;recoveredAddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;expectedAddress&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&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;Put into words, it goes like this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The client claims "I am Address &lt;code&gt;0x9906...&lt;/code&gt;"&lt;/li&gt;
&lt;li&gt;At the same time it attaches a signature over the payload&lt;/li&gt;
&lt;li&gt;The server recovers "the address of whoever made this signature" from the signature&lt;/li&gt;
&lt;li&gt;If the recovered address matches the claimed address, it is genuine&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A third party without the private key cannot produce a signature that recovers to the right address in step 3. So "just knowing the ID" does not get you through. That is why authentication holds up even with a public ID.&lt;/p&gt;

&lt;h3&gt;
  
  
  One full round trip of a request
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;verify()&lt;/code&gt; in &lt;code&gt;@agentauth/sdk&lt;/code&gt; is the entry point on the server side. The request carries three HTTP headers.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Header&lt;/th&gt;
&lt;th&gt;Contents&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;x-agentauth-address&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The claimed Address (&lt;code&gt;0x...&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;x-agentauth-payload&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;base64-encoded JSON, containing a &lt;code&gt;timestamp&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;x-agentauth-signature&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The signature over the payload (&lt;code&gt;0x...&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Following one exchange as a sequence looks like this. The color bands mark the client-side signing (red), the server-side verification (blue), success (green), and failure (gray).&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F03-request-roundtrip-sequence.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F03-request-roundtrip-sequence.png" alt="One request round trip: the client signs, the server runs verify() with four stateless checks, then branches on success or failure" width="800" height="984"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;verify()&lt;/code&gt; code is those four steps written out directly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{}):&lt;/span&gt; &lt;span class="nx"&gt;VerificationResult&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;freshness&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;freshness&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// default 60s&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;agentauth_address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-agentauth-address&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-agentauth-signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payloadB64&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-agentauth-payload&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;agentauth_address&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;payloadB64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// 1. Restore the payload&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Buffer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payloadB64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;base64&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="c1"&gt;// 2. Verify the signature (ecrecover, then compare Address)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;agentauth_address&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="c1"&gt;// 3. Freshness check on the timestamp (replay protection)&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;freshness&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;// 4. Generate the stable UUID and return it&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;agentauth_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;generateId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;agentauth_address&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;What stands out is that the server completes verification &lt;strong&gt;without holding any state at all&lt;/strong&gt; (no session, no recorded nonce). No DB lookup, no cache. It decides on the signature and the timestamp alone. That is why it gets called "zero infrastructure."&lt;/p&gt;

&lt;h3&gt;
  
  
  What the 60-second timestamp is for
&lt;/h3&gt;

&lt;p&gt;The constraint in step 3, that the &lt;code&gt;timestamp&lt;/code&gt; be within 60 seconds, is replay-attack protection.&lt;/p&gt;

&lt;p&gt;It stops an attacker who eavesdropped a signed request from re-sending it verbatim (replaying it) to impersonate. Because "when it was made" is part of what was signed, a signature older than 60 seconds cannot be reused. Instead of recording nonces on the server, it rejects by a time window. That is the trade-off with the stateless design, settled here with a "short window."&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-on: run the derivation chain yourself
&lt;/h2&gt;

&lt;p&gt;Theory alone does not stick, so let me run it locally. Hit &lt;code&gt;@agentauth/core&lt;/code&gt; directly and confirm that Token to Address to ID derives deterministically.&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;mkdir &lt;/span&gt;agentauth-play &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;agentauth-play
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm pkg &lt;span class="nb"&gt;set type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;module
npm &lt;span class="nb"&gt;install&lt;/span&gt; @agentauth/core
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Write &lt;code&gt;play.mjs&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;generateIdentity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;deriveAddress&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;generateId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;signPayload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@agentauth/core&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// 1. Generate a new agent identity&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateIdentity&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Token  :&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// aa-... (secret. this is the only thing that matters)&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Address:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_address&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;  &lt;span class="c1"&gt;// 0x...&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ID     :&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;       &lt;span class="c1"&gt;// UUID&lt;/span&gt;

&lt;span class="c1"&gt;// 2. Determinism check: derive from the Token any number of times, same result&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;addr2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deriveAddress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id2&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generateId&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;addr2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;re-derive matches:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id2&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;

&lt;span class="c1"&gt;// 3. Sign and verify&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toISOString&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;get-forecast&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;signPayload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_token&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;valid signature passes:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_address&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// true&lt;/span&gt;

&lt;span class="c1"&gt;// 4. Tamper with the payload by even one character and it fails&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tampered&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="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;action&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;delete-everything&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tampering is rejected :&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;verifySignature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;tampered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_address&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// false&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;node play.mjs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once &lt;code&gt;re-derive matches: true&lt;/code&gt; shows up, you feel that the same ID always reproduces from the Token alone. &lt;code&gt;tampering is rejected: false&lt;/code&gt; shows that the signature is bound to the payload. The server just has to trust the result of &lt;code&gt;verifySignature&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Wiring it into a server
&lt;/h3&gt;

&lt;p&gt;On the MCP server side you call &lt;code&gt;verify()&lt;/code&gt; from &lt;code&gt;@agentauth/sdk&lt;/code&gt; in middleware or at the top of each tool. The official weather-server sample is the clearest example.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;verify&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;verifyAgentAuth&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;@agentauth/sdk&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Attach an auth context in Express middleware&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;_res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;authResult&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;verifyAgentAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;authResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;valid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Authenticated: you have a stable UUID in hand&lt;/span&gt;
    &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;clientId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authResult&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;extra&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;agentauth&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;authResult&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="c1"&gt;// Do not reject the unauthenticated (let them through as anonymous)&lt;/span&gt;
  &lt;span class="nf"&gt;next&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;Inside a tool you can change behavior based on "authenticated or not." The weather-server splits a free and premium tier exactly on the presence of this ID.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;server&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;get-alerts&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Weather alerts (auth required)&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;state&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;auth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getAuthForCurrentRequest&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;text&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;🔒 This feature requires authentication&lt;/span&gt;&lt;span class="dl"&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="c1"&gt;// Use auth.agentauth_id for billing, rate limiting, usage logging&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`✅ Agent &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;agentauth_id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; accessed`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// ... return the real data ...&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;Client-side config is just dropping the Token into the MCP client's config file. Write it once and you connect to any AgentAuth-aware remote MCP server with the same Token.&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;"weather"&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;"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;"agentauth-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;"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;"connect"&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://example.com/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;"AGENTAUTH_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aa-..."&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;The &lt;a href="https://github.com/agentauthco/mcp-gateway" rel="noopener noreferrer"&gt;&lt;code&gt;agentauth-mcp&lt;/code&gt; proxy&lt;/a&gt; signs each request with the Token and attaches the three headers automatically. The Token itself never takes a step outside your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Package layout: who uses which
&lt;/h2&gt;

&lt;p&gt;Putting the division of labor on one page looks like this.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F04-package-layout.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fagentauth-self-authenticating-uuid%2Fdiagrams%2F04-package-layout.png" alt="Package layout: who uses @agentauth/mcp, @agentauth/sdk, and @agentauth/core" width="800" height="1070"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@agentauth/core&lt;/code&gt;&lt;/strong&gt;: the crypto foundation. Just the derive / sign / verify functions. You normally do not touch it directly&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@agentauth/sdk&lt;/code&gt;&lt;/strong&gt;: used by MCP server developers. You just call &lt;code&gt;verify({ headers })&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;@agentauth/mcp&lt;/code&gt;&lt;/strong&gt;: the proxy-plus-CLI used by agent users. &lt;code&gt;generate&lt;/code&gt; issues a Token, &lt;code&gt;connect&lt;/code&gt; makes a signed connection. It now lives separately at &lt;a href="https://github.com/agentauthco/mcp-gateway" rel="noopener noreferrer"&gt;agentauthco/mcp-gateway&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where to use it and where not to: dividing the field with MCP's official OAuth 2.1
&lt;/h2&gt;

&lt;p&gt;This is the most important part. Jump to "so I should use this for MCP auth" and you will crash. AgentAuth and MCP's official authorization &lt;strong&gt;cover fundamentally different ground.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The official MCP spec firmed up its authorization on an OAuth 2.1 base over the course of 2025. Worth tracing the flow.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;2025-03&lt;/strong&gt;: put remote MCP server authorization on an OAuth 2.1 base. Introduced authorization-server metadata discovery (RFC 8414)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2025-06-18&lt;/strong&gt;: classified the MCP server clearly as an "OAuth 2.1 Resource Server." Made Protected Resource Metadata (RFC 9728), Resource Indicators (RFC 8707, binding a token's audience), and PKCE mandatory. Banned token passthrough (forwarding a received token straight to an upstream API as-is) and closed the confused-deputy hole where authority gets misused by mistake&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2025-11-25 (the current latest)&lt;/strong&gt;: made PKCE require S256 when technically capable, and the &lt;code&gt;resource&lt;/code&gt; parameter always mandatory. Promoted Client ID Metadata Documents (CIMD) to the recommended registration path, demoting the older Dynamic Client Registration (DCR) to a backward-compatible option&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, what the official MCP spec is solving is the &lt;strong&gt;delegation&lt;/strong&gt; problem: "as a stand-in for the human Alice, an agent reaches a third-party resource (like GitHub) with narrowed scope." Consent screens, scopes, audience binding, IdP integration are the stars.&lt;/p&gt;

&lt;p&gt;What AgentAuth provides is only &lt;strong&gt;"who is this agent (identity) plus is it really them (authentication)."&lt;/strong&gt; No scopes, no consent, no delegation. Putting the positioning in a table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;AgentAuth&lt;/th&gt;
&lt;th&gt;Official MCP OAuth 2.1&lt;/th&gt;
&lt;th&gt;Plain API key&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Identity source&lt;/td&gt;
&lt;td&gt;self-derived from a local key (no issuer)&lt;/td&gt;
&lt;td&gt;issued by an authorization server&lt;/td&gt;
&lt;td&gt;issued by the server&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Backend&lt;/td&gt;
&lt;td&gt;none (stateless verify)&lt;/td&gt;
&lt;td&gt;authorization server + metadata endpoints&lt;/td&gt;
&lt;td&gt;key store&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Registration / login&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;DCR/CIMD + consent flow&lt;/td&gt;
&lt;td&gt;manual issuance&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Authorization (scopes)&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;none&lt;/strong&gt; (identity only)&lt;/td&gt;
&lt;td&gt;yes (scopes, audience binding, RBAC)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Human in the loop&lt;/td&gt;
&lt;td&gt;not needed&lt;/td&gt;
&lt;td&gt;needed (consent, redirect)&lt;/td&gt;
&lt;td&gt;not needed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credential lifetime&lt;/td&gt;
&lt;td&gt;long-lived private key (password-equivalent)&lt;/td&gt;
&lt;td&gt;short-lived scoped tokens&lt;/td&gt;
&lt;td&gt;long-lived&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Replay protection&lt;/td&gt;
&lt;td&gt;signature + 60s time window&lt;/td&gt;
&lt;td&gt;TLS + short-lived tokens&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The decision guide sorts out like this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AgentAuth fits&lt;/strong&gt;: you control both ends (client and server) and want to "tell agents apart by a stable ID, block impersonation, but not run authorization infra." Usage tracking, free/paid gating, anonymous-but-consistent analytics&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth 2.1 is required&lt;/strong&gt;: calling third-party APIs on a user's behalf, narrowing scopes, taking consent, integrating with a corporate IdP. The moment delegation enters, this becomes mandatory&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two are not mutually exclusive, they compose. &lt;strong&gt;Agent identity by AgentAuth, resource authorization by OAuth&lt;/strong&gt; is a stacking that holds up in principle. Read AgentAuth as "a self-sovereign API key that is also a verifiable, stable UUID" and you will not misplace it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Realities to know before adopting
&lt;/h2&gt;

&lt;p&gt;The design is clean, but if you are weighing production, here is the honest part too.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Still very young&lt;/strong&gt;: &lt;code&gt;@agentauth/core&lt;/code&gt; is at v0.1.1. First published June 2025, and quiet since. Monthly npm downloads are in the dozens per package, so a track record in the field is still ahead. Excellent as something to dissect for an interesting design, but not a "battle-aged standard"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The name collision is bad&lt;/strong&gt;: this space is flooded with "Agent Auth" style names. The AgentAuth at agentauth.co is a different thing from Dick Hardt's &lt;strong&gt;AAuth&lt;/strong&gt;, from Better Auth's &lt;strong&gt;Agent Auth plugin&lt;/strong&gt;, and from Microsoft Foundry's agent identity. Watch out when searching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The long-lived private key trade-off&lt;/strong&gt;: the Token is password-equivalent and long-lived. Leak it and the ID tied to that key is taken over wholesale. Be aware this runs in the opposite direction from the world of rotating short-lived tokens (WIF, ID-JAG)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization is your own job&lt;/strong&gt;: "what it is allowed to do" sits outside AgentAuth. After making the ID a DB primary key, you still have to design permission management yourself&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;AgentAuth in one line is &lt;strong&gt;"a UUID derived deterministically from a private key that can authenticate itself."&lt;/strong&gt; Stacking the mechanism from the bottom up, it connects like this.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The Token is a secp256k1 private key. From it you derive an Address Ethereum-style, then an AgentAuth ID via UUIDv5 (one-way)&lt;/li&gt;
&lt;li&gt;The ID is public, but every request is signed with the private key, so it cannot be impersonated&lt;/li&gt;
&lt;li&gt;The server recovers the Address from the signature and compares, plus rejects replays with a 60-second window. It holds no state at all&lt;/li&gt;
&lt;li&gt;The result: a "stable identity plus authentication" with no registration, no login, no session DB&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I think it is a clean fit of public-key crypto's "keep the private key in hand, show only public values" property onto agent authentication. But this is a tool for &lt;strong&gt;authentication (authn)&lt;/strong&gt;, not &lt;strong&gt;authorization (authz)&lt;/strong&gt;. Keep the line straight, that it does not compete with MCP's official OAuth 2.1 but covers different ground, and where it lands is clear.&lt;/p&gt;

&lt;p&gt;If you want to get your hands on it, run the hands-on above with &lt;code&gt;npm install @agentauth/core&lt;/code&gt; first, and see for yourself the determinism of Token to ID and signature verification rejecting tampering. Once that clicks, you are halfway to seeing what is interesting about this design.&lt;/p&gt;

&lt;p&gt;Reference links:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/agentauthco/agentauth" rel="noopener noreferrer"&gt;AgentAuth repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/agentauthco/agentauth/tree/main/packages/agentauth-core" rel="noopener noreferrer"&gt;@agentauth/core source&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization" rel="noopener noreferrer"&gt;MCP Authorization spec (2025-06-18)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization" rel="noopener noreferrer"&gt;MCP Authorization spec (2025-11-25, current)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>mcp</category>
      <category>typescript</category>
    </item>
    <item>
      <title>JWT (Access Token) vs X.509 Deep Dive: How to Choose What You Present as a Credential</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Mon, 15 Jun 2026 13:54:03 +0000</pubDate>
      <link>https://dev.to/kanywst/jwt-access-token-vs-x509-deep-dive-how-to-choose-what-you-present-as-a-credential-34ij</link>
      <guid>https://dev.to/kanywst/jwt-access-token-vs-x509-deep-dive-how-to-choose-what-you-present-as-a-credential-34ij</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I was poking around a service mesh when something struck me as odd.&lt;/p&gt;

&lt;p&gt;Inside the mesh, Pod-to-Pod traffic authenticated with &lt;strong&gt;mTLS&lt;/strong&gt;: both sides showed each other an X.509 certificate. But the same request, when it came from outside through an API Gateway, carried a &lt;strong&gt;JWT&lt;/strong&gt; in &lt;code&gt;Authorization: Bearer eyJ...&lt;/code&gt;. Same job ("who are you, and may you come in"), yet the thing being handed over changed depending on where you stood.&lt;/p&gt;

&lt;p&gt;At first I assumed it was just historical mess. The more I dug, though, the more it turned out to be a clean split that comes down to &lt;strong&gt;one axis&lt;/strong&gt;: "bearer, or proof-of-possession?"&lt;/p&gt;

&lt;p&gt;This article lines up JWT (access tokens) and X.509 (mTLS) from the angle of "what do you present as a credential," and walks through when to pick which, top to bottom. It starts from the basics, so you can follow it even if you are not deep into OAuth or TLS.&lt;/p&gt;




&lt;h2&gt;
  
  
  Setup: what "present a credential to get authorized" actually means
&lt;/h2&gt;

&lt;p&gt;Let me get the vocabulary straight first. The "getting authorized" scene that runs through this whole article has three actors.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F01-setup-three-actors.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F01-setup-three-actors.png" alt="Three actors: issuer, presenter, verifier, and the allow/deny decision" width="800" height="1453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Issuer&lt;/strong&gt;: creates the credential and hands it out. For a JWT that is the IdP (authorization server); for X.509 it is the Certificate Authority (CA).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Presenter&lt;/strong&gt;: holds the credential it received and shows it to the other side on every request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verifier&lt;/strong&gt;: checks whether the presented credential is genuine and whose it is, then decides to let it through or reject it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A "credential" is the &lt;strong&gt;ID badge&lt;/strong&gt; you present in step 2. It is structurally the same as showing your driver's license to prove your age. The question is what you use as that badge. Here the path forks into two families.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JWT (access token)&lt;/strong&gt;: a signed JSON, sent in the &lt;code&gt;Authorization&lt;/code&gt; header.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X.509 certificate&lt;/strong&gt;: a digital certificate presented during the TLS handshake. The "m" in mTLS (mutual) is exactly this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two differ in how they look and where they ride, but the essential difference is a single thing. That is next.&lt;/p&gt;




&lt;h2&gt;
  
  
  The core: bearer, or proof-of-possession
&lt;/h2&gt;

&lt;p&gt;Every difference grows from here. Credentials split into two kinds by "can you use it just by holding it."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bearer type&lt;/strong&gt;: anyone holding the thing can use it. Like &lt;strong&gt;cash&lt;/strong&gt;. Drop your cash and whoever picks it up can spend it. JWT access tokens are basically this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Proof-of-possession type&lt;/strong&gt;: showing the thing is not enough. You have to &lt;strong&gt;prove every time&lt;/strong&gt; that it is really yours. Closer to a &lt;strong&gt;bank card with a PIN&lt;/strong&gt;. Steal the card and you still cannot move money without the PIN (the private key). X.509 + mTLS is this.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F02-bearer-vs-pop.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F02-bearer-vs-pop.png" alt="Bearer type is usable once stolen; proof-of-possession needs the private key" width="800" height="805"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This one picture is the most important in the article. Almost everything else is a consequence of it.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If a JWT is stolen, the thief gets to act as "you" directly (a replay attack). That is why the standard move for JWTs is to &lt;strong&gt;keep their lifetime short&lt;/strong&gt; so the damage window stays small.&lt;/li&gt;
&lt;li&gt;An X.509 certificate is more or less public information, but you cannot open a TLS session without holding the &lt;strong&gt;matching private key&lt;/strong&gt;. Sniff the wire and copy the certificate, and you still cannot impersonate "you," because you lack the private key.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"So just always use X.509, right?" Not quite. Proof-of-possession carries its own costs (distributing, storing, and rotating private keys, plus the proxy problem covered later). That is exactly why you need to choose.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cracking each one open
&lt;/h2&gt;

&lt;p&gt;Now that the axis is clear, let me look at the two real artifacts.&lt;/p&gt;

&lt;h3&gt;
  
  
  JWT (access token): a signed JSON flowing through the app layer
&lt;/h3&gt;

&lt;p&gt;A JWT is a string split into three parts by &lt;code&gt;.&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;eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9 . eyJzdWIiOiJzdmMtYSIsImF1ZCI6... . SflKxwRJSMeKKF2QT4f...
|------------ header -----------|    |--------- payload --------|    |----- signature ----|
  alg (sign method), typ              sub (who), aud (for whom), exp    signed with private key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verifier checks the signature using the key set the issuer publishes (the &lt;strong&gt;JWKS&lt;/strong&gt;: JSON Web Key Set). The point is that the &lt;strong&gt;verifier does not have to call the issuer&lt;/strong&gt;. If the signature checks out, it can trust the token's contents (who, until when, for whom). This is &lt;strong&gt;stateless verification&lt;/strong&gt;. Delivery is via an HTTP header.&lt;/p&gt;

&lt;p&gt;There are also access tokens whose contents are not a JWT, the &lt;strong&gt;opaque tokens&lt;/strong&gt;. For those the verifier calls the issuer's introspection endpoint on every request to confirm validity (stateful). When this article says "access token," it means the &lt;strong&gt;JWT form&lt;/strong&gt; that supports the stateless verification above.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GET /api/orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiI...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Since this lives at the application layer (L7), anything that speaks HTTP can carry it. A proxy or a Gateway can pass it straight through as "just a header." That matters later.&lt;/p&gt;

&lt;h3&gt;
  
  
  X.509 certificate: an identity proven during the TLS handshake
&lt;/h3&gt;

&lt;p&gt;X.509 is presented in the middle of opening a TLS connection. Ordinary HTTPS has only the server present a certificate, but in &lt;strong&gt;mTLS&lt;/strong&gt; the client presents one too, and on top of that &lt;strong&gt;proves possession by signing with its private key&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F03-mtls-handshake.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F03-mtls-handshake.png" alt="mTLS handshake: the client proves key possession before any HTTP data flows" width="800" height="661"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The verifier walks the certificate up to a CA it trusts (the &lt;strong&gt;trust bundle&lt;/strong&gt;) to confirm legitimacy, and then uses the &lt;code&gt;CertificateVerify&lt;/code&gt; signature to confirm the peer "really holds the private key." The defining trait is that authentication finishes &lt;strong&gt;before a single byte of application data flows&lt;/strong&gt;, and this is transport-layer (closer to L4) work.&lt;/p&gt;

&lt;p&gt;That pins down where each one lives: a JWT is &lt;strong&gt;data flowing through the app layer&lt;/strong&gt;, and X.509 is &lt;strong&gt;an identity carved into the connection itself&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Lining them up on seven axes
&lt;/h2&gt;

&lt;p&gt;With both artifacts in view, let me put them side by side. First a table for the big picture, then notes on the axes that matter most.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Axis&lt;/th&gt;
&lt;th&gt;JWT (access token)&lt;/th&gt;
&lt;th&gt;X.509 (mTLS)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Trust model&lt;/td&gt;
&lt;td&gt;Bearer&lt;/td&gt;
&lt;td&gt;Proof-of-possession&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Theft resistance&lt;/td&gt;
&lt;td&gt;Weak (steal it, use it)&lt;/td&gt;
&lt;td&gt;Strong (useless without the private key)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Layer it works at&lt;/td&gt;
&lt;td&gt;App layer (L7 / HTTP header)&lt;/td&gt;
&lt;td&gt;Transport layer (TLS handshake)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Survives a proxy / LB&lt;/td&gt;
&lt;td&gt;Yes (just a header)&lt;/td&gt;
&lt;td&gt;No (mTLS is terminated there)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Verification&lt;/td&gt;
&lt;td&gt;Check signature via JWKS (stateless)&lt;/td&gt;
&lt;td&gt;CA chain (trust bundle) + signature check&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revocation&lt;/td&gt;
&lt;td&gt;Let it expire via a short &lt;code&gt;exp&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;CRL / OCSP, or short-lived certs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational cost&lt;/td&gt;
&lt;td&gt;Low (no key distribution)&lt;/td&gt;
&lt;td&gt;High (distribute, store, renew keys). Automated in a mesh&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three axes are worth expanding.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 1: can it cross a proxy
&lt;/h3&gt;

&lt;p&gt;This is the one that bites hardest in production. mTLS is authentication bound to "&lt;strong&gt;this point-to-point connection&lt;/strong&gt;." Put an L7 load balancer or API Gateway in the middle that terminates TLS, and the client's identity &lt;strong&gt;vanishes&lt;/strong&gt; there. From a service deeper than the Gateway, the peer is the Gateway, not the original 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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F04-proxy-traversal.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F04-proxy-traversal.png" alt="At a TLS-terminating proxy, mTLS identity vanishes while a JWT passes through as a header" width="800" height="758"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A JWT is "data," so the Gateway can forward it deeper as a header untouched. So &lt;strong&gt;if you want to carry identity end to end across several proxy hops, use a JWT&lt;/strong&gt;. That is the answer to the mystery from the opening: cross the Gateway and it becomes a JWT.&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 2: how you revoke
&lt;/h3&gt;

&lt;p&gt;When you want to cancel a credential, the two strategies are inverted.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JWT&lt;/strong&gt;: once a signed token is out, the verifier never calls the issuer, so "revoke it after the fact" is hard. So you make the lifetime &lt;strong&gt;short from the start&lt;/strong&gt; (minutes to tens of minutes) and wait for a leaked one to expire on its own. A long-lived JWT is a red flag.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X.509&lt;/strong&gt;: you can announce "this certificate is no longer valid" via a revocation list (&lt;strong&gt;CRL&lt;/strong&gt;) or &lt;strong&gt;OCSP&lt;/strong&gt;. But the verifier has to go check that, which is operationally heavy. So rather than leaning on CRLs, the current trend is to make the &lt;strong&gt;certificate itself short-lived&lt;/strong&gt; (SPIRE defaults to one hour, for example) and reissue it frequently.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The funny part is that both roads end up in the same place: "&lt;strong&gt;keep it short-lived to dodge the problem.&lt;/strong&gt;"&lt;/p&gt;

&lt;h3&gt;
  
  
  Axis 3: operational cost
&lt;/h3&gt;

&lt;p&gt;X.509 means handing a private key to each workload, storing it safely, and renewing it on a schedule. That is the real source of "certificates are painful." But today a service mesh like Istio or Linkerd, or SPIFFE/SPIRE, does &lt;strong&gt;this distribution and renewal fully automatically&lt;/strong&gt;. The old common sense that "mTLS is operationally heavy," from the days of handing out certificates by hand, has largely faded in a mesh.&lt;/p&gt;

&lt;p&gt;The JWT side does not need to distribute a private key to clients (only the issuer holds the signing key), and the verifier just pulls the public key from the JWKS. The barrier to adoption is low.&lt;/p&gt;




&lt;h2&gt;
  
  
  Know how each one breaks
&lt;/h2&gt;

&lt;p&gt;Before you choose, it helps to know how each gets defeated, so your judgment does not wobble. The entry point for an attacker is completely different between the two.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JWT &lt;code&gt;alg:none&lt;/code&gt;&lt;/strong&gt;: the biggest landmine in JWT. Set the header &lt;code&gt;alg&lt;/code&gt; to &lt;code&gt;none&lt;/code&gt; and a buggy implementation may accept "valid even without a signature," a classic vulnerability. You close it with a sane library and an operational rule that pins the allowed &lt;code&gt;alg&lt;/code&gt; (the SPIFFE JWT-SVID spec limits &lt;code&gt;alg&lt;/code&gt; to nine values and rejects &lt;code&gt;none&lt;/code&gt; and the symmetric &lt;code&gt;HS*&lt;/code&gt; family).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JWT theft and replay&lt;/strong&gt;: the bearer curse. Minimize the damage with a short &lt;code&gt;exp&lt;/code&gt;, sending over TLS, and never logging it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X.509 private key leak&lt;/strong&gt;: since possession of the private key is the whole basis of proof, a leaked key is game over. So keep keys off plain disk, ideally locked inside a TPM / HSM or in memory, and rotate them by keeping them short-lived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;X.509 CA compromise&lt;/strong&gt;: take over the CA and you can mint any fake identity. Managing the trust bundle matters.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;X.509 says "as long as you protect the private key, being watched on the wire is fine." JWT says "as long as you protect the channel (TLS) it is easy to adopt, but a leak means instant impersonation." The point you have to defend is different.&lt;/p&gt;




&lt;h2&gt;
  
  
  The hybrid: bearer ergonomics + proof-of-possession strength
&lt;/h2&gt;

&lt;p&gt;"I want the convenience of a JWT, but I do not want it used after being stolen." Two mechanisms answer that greed. Both bolt proof-of-possession onto a JWT.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F05-hybrid-pop.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F05-hybrid-pop.png" alt="Cert-bound tokens and DPoP both add proof-of-possession to a plain JWT" width="799" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Certificate-bound access token (RFC 8705)&lt;/strong&gt;: at issuance, the hash (thumbprint) of the client certificate is stamped into the token. The verifier checks that "the thumbprint written in the token" matches "the certificate on the mTLS connection it currently holds." A stolen token alone is useless because you cannot open the matching mTLS connection. Used in high-security domains like Open Banking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DPoP&lt;/strong&gt;: for situations where you cannot run mTLS, it binds the token to a key the client holds, at the app layer. A "proof" signed with that key is attached per request, and the verifier cross-checks it against the token.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In short, "bearer or PoP" is not 0 or 1. You can &lt;strong&gt;add PoP to a JWT and slide toward the middle&lt;/strong&gt;. But every addition costs implementation and operational effort, so for the first decision the plain two-way split is enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  Choosing by example: the SPIFFE guidance makes it concrete
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;SPIFFE&lt;/strong&gt;, the standard for workload identity, carries both formats: the certificate flavor &lt;strong&gt;X509-SVID&lt;/strong&gt; and the JWT flavor &lt;strong&gt;JWT-SVID&lt;/strong&gt;. And the guidance in its docs is blunt, which makes a ready-to-use decision rule. Summarized:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Because they are susceptible to replay attacks, use X.509-SVIDs whenever possible. Use JWT-SVID when mTLS is not practical, such as when an L7 proxy or load balancer sits between workloads.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Walk a worked example. An order service calls an inventory service.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Inside the mesh, Pod to Pod, direct&lt;/strong&gt;: no L7 proxy in between, and the sidecar rotates certificates for you. Use &lt;strong&gt;X509-SVID (mTLS)&lt;/strong&gt;. Sniffing is useless without the private key, so it resists replay.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Across an API Gateway from outside, or with an L7 LB in between&lt;/strong&gt;: mTLS gets terminated at the Gateway and identity vanishes. Put a &lt;strong&gt;JWT-SVID&lt;/strong&gt; in a header and carry it end to end. In return, keep &lt;code&gt;exp&lt;/code&gt; short (a few minutes) to bound the theft risk.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F06-decision-tree.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fjwt-vs-x509-credential%2Fdiagrams%2F06-decision-tree.png" alt="Decision tree: proxy in between, key automation, and replay defense lead to JWT or X.509" width="800" height="976"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In real production the standard is a hybrid: "JWT (OAuth) at the user edge, mTLS between services." You do not have to commit to one. The right answer is to &lt;strong&gt;choose per boundary using the decision tree above&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference
&lt;/h2&gt;

&lt;p&gt;To put the decision in one place.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;When&lt;/th&gt;
&lt;th&gt;Pick&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Service to service, direct inside a mesh&lt;/td&gt;
&lt;td&gt;X.509 + mTLS&lt;/td&gt;
&lt;td&gt;Auto-renewal works, resists replay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Crossing an L7 proxy / Gateway&lt;/td&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;mTLS is terminated and identity vanishes; a JWT survives as a header&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;From a browser / mobile&lt;/td&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;Distributing a private key to the client is impractical&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Many services trust the same issuer&lt;/td&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;Verifiable via JWKS, no certificate distribution&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strong defense against replay needed&lt;/td&gt;
&lt;td&gt;X.509, or a cert-bound / DPoP JWT&lt;/td&gt;
&lt;td&gt;Close the bearer weakness with proof-of-possession&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Must revoke immediately&lt;/td&gt;
&lt;td&gt;X.509 (CRL/OCSP), or a short-lived JWT&lt;/td&gt;
&lt;td&gt;JWT is bad at after-the-fact revocation; substitute short lifetimes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;And the one line to remember goes back to the first picture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A JWT works if you hold it (bearer). X.509 works if you hold it and can prove it with the private key (proof-of-possession).&lt;/strong&gt; Almost every other difference follows from that.&lt;/p&gt;




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

&lt;ul&gt;
&lt;li&gt;The two families of credential you hand over for authorization, JWT (access token) and X.509 (mTLS), can be organized along one axis: &lt;strong&gt;bearer or proof-of-possession&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;A JWT is easy and crosses proxies, but a stolen one gets used. So you protect it by keeping it &lt;strong&gt;short-lived&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;X.509 is strong, useless even if sniffed without the private key, but its weaknesses are that &lt;strong&gt;identity is cut off at a proxy&lt;/strong&gt; and that &lt;strong&gt;keys need operating&lt;/strong&gt;. In a mesh, automation makes the operations light.&lt;/li&gt;
&lt;li&gt;If you want both, you can bolt proof-of-possession onto a JWT with &lt;strong&gt;RFC 8705 certificate-bound tokens&lt;/strong&gt; or &lt;strong&gt;DPoP&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;In practice it is not either-or; you &lt;strong&gt;choose per boundary with the decision tree&lt;/strong&gt;. SPIFFE's "X.509 whenever possible, JWT when a proxy is in the way" works directly as the rule.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Next time you look at your own system, go boundary by boundary and check, "is what flows here a bearer, or a proof-of-possession?" If a long-lived bearer token is flowing along a near-plaintext path, that is your most dangerous spot.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authentication</category>
      <category>jwt</category>
      <category>tls</category>
    </item>
    <item>
      <title>a2claude: Turn Claude Code Into a Server Other AI Agents Can Call</title>
      <dc:creator>kt</dc:creator>
      <pubDate>Sun, 14 Jun 2026 07:54:12 +0000</pubDate>
      <link>https://dev.to/kanywst/a2claude-turn-claude-code-into-a-server-other-ai-agents-can-call-1mf6</link>
      <guid>https://dev.to/kanywst/a2claude-turn-claude-code-into-a-server-other-ai-agents-can-call-1mf6</guid>
      <description>&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;Running a single AI agent is starting to feel dated. You have an agent that researches, one that plans, one that writes code, each with a role, handing work to each other. When I tried to wire up a setup like that, I got stuck on one question: what do I use for the "writes code" slot?&lt;/p&gt;

&lt;p&gt;I already had Claude Code. It actually runs tools, edits files, and runs tests for you. The catch is that Claude Code is built to be driven by a human sitting at a terminal. There is no built-in door for another agent to call it automatically.&lt;/p&gt;

&lt;p&gt;So I wrote a small tool that exposes Claude Code as an &lt;strong&gt;A2A protocol server&lt;/strong&gt;. It is called &lt;code&gt;a2claude&lt;/code&gt;. Another agent sends "implement this feature" over A2A, a real Claude Code session runs against the project you pointed it at, and the result streams back. And what comes back is not a blob of text: it is structured information about which tools ran, which files changed and how, what it cost, and which actions need approval.&lt;/p&gt;

&lt;p&gt;The repo is here: &lt;a href="https://github.com/kanywst/a2claude" rel="noopener noreferrer"&gt;github.com/kanywst/a2claude&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This article is written so that reading top to bottom takes you from knowing nothing about A2A or the internals of Claude Code to understanding exactly what a2claude bridges and how.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background 1: What A2A is
&lt;/h2&gt;

&lt;p&gt;A2A (Agent2Agent) is a &lt;strong&gt;communication standard for AI agents to talk to each other&lt;/strong&gt;. Google announced it in 2025, donated it to the Linux Foundation the same year, and it is now governed there under neutral stewardship (v1.0 landed in 2026). It rides on top of HTTP over one of JSON-RPC, gRPC, or REST (the spec requires all three to expose the same operations), and it standardizes the exchange where one agent asks another to do a job.&lt;/p&gt;

&lt;p&gt;There are only four terms you need to hold onto.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agent Card&lt;/strong&gt;: the business card that states what an agent can do. It lives at a fixed path, &lt;code&gt;/.well-known/agent-card.json&lt;/code&gt;, and a caller reads it first to decide whether this agent can handle what it wants done.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task&lt;/strong&gt;: one request. It carries state and moves like &lt;code&gt;submitted&lt;/code&gt; (received) → &lt;code&gt;working&lt;/code&gt; (in progress) → &lt;code&gt;completed&lt;/code&gt; (done). Along the way it can also hit &lt;code&gt;input-required&lt;/code&gt; (waiting on input) or &lt;code&gt;failed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Artifact&lt;/strong&gt;: the output of a Task. Generated text, files, and so on. It can be returned incrementally (streamed).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;contextId&lt;/strong&gt;: the conversation handle. Send the next Task with the same contextId and it is treated as a continuation of the previous one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Task state transitions look like this. It pays to keep this in your head, because how a2claude uses these states matters later.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F01-task-lifecycle.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F01-task-lifecycle.png" alt="A2A Task lifecycle, with input-required highlighted" width="800" height="846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A2A has other states too, such as &lt;code&gt;rejected&lt;/code&gt; (the agent declines the request), but the ones a2claude actually uses are in this diagram. The one in red, &lt;code&gt;input-required&lt;/code&gt;, becomes the star of the second half.&lt;/p&gt;

&lt;p&gt;The point is that A2A is a shared language between agents: it does not matter what an agent runs on the inside (Claude or some other LLM), as long as it honors the Agent Card and the Task exchange, the conversation works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background 2: What Claude Code is
&lt;/h2&gt;

&lt;p&gt;Claude Code is Anthropic's coding agent. It is not a chat that only returns prose. It also:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;runs commands with &lt;code&gt;Bash&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;reads and writes files with &lt;code&gt;Read&lt;/code&gt; / &lt;code&gt;Edit&lt;/code&gt; / &lt;code&gt;Write&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;stops to ask for approval&lt;/strong&gt; before risky actions&lt;/li&gt;
&lt;li&gt;reports the cost and turn count of a run&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In other words, it actually moves its hands. The thing to notice for this article is that when Claude Code runs, &lt;strong&gt;a lot of information beyond text comes out&lt;/strong&gt;: which tool it called, which file it changed and how, what it cost. a2claude does not throw that away. It puts it on A2A.&lt;/p&gt;

&lt;h2&gt;
  
  
  Background 3: Why a bridge is needed
&lt;/h2&gt;

&lt;p&gt;A2A is the protocol for agents to talk. Claude Code is the powerful tool that actually writes code. The two do not line up. Claude Code has no A2A server door, and from the A2A world Claude Code is invisible.&lt;/p&gt;

&lt;p&gt;So you need a translator in between. It takes an A2A Task, drives Claude Code, and translates the events Claude Code emits back into the language of A2A. That is what a2claude does.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F02-bridge-overview.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F02-bridge-overview.png" alt="a2claude sitting between the calling agent and Claude Code" width="800" height="1231"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How this differs from the usual approach
&lt;/h2&gt;

&lt;p&gt;Adapters that "wrap a coding agent in A2A" are not rare. But most of them &lt;strong&gt;flatten both input and output to text&lt;/strong&gt;. You hand over a prompt and get text back. That erases everything that happened in between. From the calling agent's point of view, you cannot see whether a file was rewritten, what spent money, or whether an action needed approval.&lt;/p&gt;

&lt;p&gt;a2claude keeps the structure that comes out of Claude Code and carries it onto A2A. The difference between the left and right of the diagram below is the whole reason this project exists.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F03-flatten-vs-structured.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F03-flatten-vs-structured.png" alt="Text-only flattening versus structured events kept by a2claude" width="799" height="339"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What maps to what
&lt;/h2&gt;

&lt;p&gt;The heart of a2claude comes down to one mapping table: "what Claude Code emits" onto "which A2A surface it lands on". This table, which is also in the README, is the core of the design.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What Claude Code emits&lt;/th&gt;
&lt;th&gt;The A2A surface it lands on&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Assistant text&lt;/td&gt;
&lt;td&gt;A streamed artifact (&lt;code&gt;append&lt;/code&gt; / &lt;code&gt;last_chunk&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A tool call (Bash, Edit, ...)&lt;/td&gt;
&lt;td&gt;A &lt;code&gt;working&lt;/code&gt; status update&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A file edit&lt;/td&gt;
&lt;td&gt;A named artifact carrying the diff&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Run result&lt;/td&gt;
&lt;td&gt;Cost / turns / usage on the completion message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Session id&lt;/td&gt;
&lt;td&gt;Mapped to the A2A &lt;code&gt;contextId&lt;/code&gt; to resume next time&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This mapping is closed up in one place in the code (&lt;code&gt;executor.py&lt;/code&gt;). That is what pays off in the next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: the A2A layer does not know Claude directly
&lt;/h2&gt;

&lt;p&gt;a2claude is split into layers. The single most important design decision is that &lt;strong&gt;the layer doing the A2A translation never imports the SDK that drives Claude Code&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In between sits an abstraction called a "backend". A backend's job is to drive Claude Code and emit &lt;strong&gt;normalized events&lt;/strong&gt; (text, tool calls, file changes, permission requests, run results). The translation layer looks only at those events and maps them onto A2A. How Claude is invoked (through the SDK today, through the raw CLI later) is none of the translation layer's business.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F04-architecture-layers.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F04-architecture-layers.png" alt="Layered architecture: the A2A layer talks to a backend abstraction, not the SDK" width="800" height="1401"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Because of this split there are two backends.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;echo&lt;/code&gt;&lt;/strong&gt;: no API key and no Claude install required. A dummy that just mirrors the input. It lets you exercise the server, the protocol mapping, and the CLI &lt;strong&gt;end to end, offline&lt;/strong&gt;. It reproduces every path, including the permission round trip that comes up later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;claude&lt;/code&gt;&lt;/strong&gt;: drives the real Claude Code through the Claude Agent SDK.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why is "the translation layer not knowing the SDK" worth it? Because testing gets easy. The &lt;code&gt;echo&lt;/code&gt; backend alone can verify all of the A2A behavior. With no API key to call Claude, even in CI, you can check that the design has not broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  Inside: an event queue and "parking"
&lt;/h2&gt;

&lt;p&gt;This is the most interesting part of a2claude. A backend's &lt;code&gt;drive&lt;/code&gt; runs as a &lt;strong&gt;background task&lt;/strong&gt; and pushes the events it produces onto a queue. The translation layer (the consumer) pulls them off in order with &lt;code&gt;drain&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;While things are flowing normally it is just a producer / consumer. The interesting case is when you hit a tool that needs approval. Claude Code stops and waits. At that point a2claude &lt;strong&gt;parks the background task right at the permission request&lt;/strong&gt;, and pushes only a "please approve this" event onto the queue. &lt;code&gt;drain&lt;/code&gt; returns that one event and then halts.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F05-queue-and-park.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F05-queue-and-park.png" alt="The event queue: drain stops when a permission request parks the background task" width="800" height="609"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;While it is halted, the Claude session does not die: it waits. Later, when the caller sends its answer, the parked task resumes and starts flowing again. This trick of "keeping the session alive across two separate calls" is what makes the next section's permission exchange possible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Permissions: do not auto-approve, do not silently skip
&lt;/h2&gt;

&lt;p&gt;How you handle actions that need approval is the most nerve-wracking part of opening a coding agent up to someone else (another agent). A sloppy implementation either "auto-approves everything" or "silently skips anything that needs approval". Both are dangerous.&lt;/p&gt;

&lt;p&gt;a2claude uses the &lt;strong&gt;A2A &lt;code&gt;input-required&lt;/code&gt; state&lt;/strong&gt;. This is not something a2claude invented: A2A defines it precisely for human-in-the-loop, where a human or the caller makes a decision mid-task. Per the spec, when a Task reaches &lt;code&gt;input-required&lt;/code&gt; the processing stops there and control returns to the caller. a2claude rides directly on that.&lt;/p&gt;

&lt;p&gt;When a tool that needs approval shows up, the Task stops at &lt;code&gt;input-required&lt;/code&gt; and asks the caller "approve this action?". The caller sends its answer as a message &lt;strong&gt;on the same Task&lt;/strong&gt;. &lt;code&gt;allow&lt;/code&gt; (or &lt;code&gt;yes&lt;/code&gt;, &lt;code&gt;approve&lt;/code&gt;, &lt;code&gt;ok&lt;/code&gt;) approves; anything else denies.&lt;/p&gt;

&lt;p&gt;Thanks to the park from the previous section, the Claude session is still alive while stopped, so when the approval comes back it picks up &lt;strong&gt;right where it left off&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F06-permission-round-trip.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F06-permission-round-trip.png" alt="Permission round-trip across two calls while the session stays parked" width="800" height="835"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;One more important point: &lt;strong&gt;the server does not inherit the personal Claude settings of whoever runs it&lt;/strong&gt;. Your local Claude Code has a pre-approved tool allowlist ("this tool is always OK"), but the server does not load it. So when it acts on behalf of another agent, any action needing approval always routes back to the caller for a decision. Read-only actions that are already considered safe still run without a prompt, as before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Session continuity: remembering "the previous turn"
&lt;/h2&gt;

&lt;p&gt;Claude Code holds a conversation session and can keep working with the prior context. a2claude &lt;strong&gt;ties that Claude session id to the A2A contextId&lt;/strong&gt; and remembers it. Send the next Task with the same contextId and the same Claude conversation resumes.&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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F07-session-continuity.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%2Fraw.githubusercontent.com%2F0-draft%2Fdev.to%2Fmain%2Farticles%2Fassets%2Fa2claude-claude-code-as-a2a-agent%2Fdiagrams%2F07-session-continuity.png" alt="Session continuity: the same contextId resumes the same Claude conversation" width="800" height="846"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;"Add a &lt;code&gt;/health&lt;/code&gt; endpoint" → "now add a test for it" goes through as one continuous piece of work, not two separate requests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;From here on we actually run it. You need three things.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Python 3.13 or newer&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.astral.sh/uv/" rel="noopener noreferrer"&gt;uv&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;(only for the &lt;code&gt;claude&lt;/code&gt; backend) the Claude Code CLI on your &lt;code&gt;PATH&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;First clone and install dependencies.&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/kanywst/a2claude
&lt;span class="nb"&gt;cd &lt;/span&gt;a2claude
uv &lt;span class="nb"&gt;sync&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  First, offline (the echo backend)
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;echo&lt;/code&gt; backend needs no API key and no Claude install. Running the whole path offline first puts your mind at ease. Start the server and call it from another mouth.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run a2claude serve &lt;span class="nt"&gt;--backend&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &amp;amp;
uv run a2claude call &lt;span class="s2"&gt;"fix the failing test"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What comes back looks like this. A Task and context id are assigned, and after the stream the completion metadata (cost and turn count) is attached.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;task 189b1c63-1a7b-4908-87c4-c8f3bba8f6b5
context 0b2a901e-2b6f-4c56-bba2-d0da546936e9

  · Echo
fix the failing test
[completed] $0.0 · 1 turns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Point it at a real project (the claude backend)
&lt;/h3&gt;

&lt;p&gt;Now the &lt;code&gt;claude&lt;/code&gt; backend. Use &lt;code&gt;--cwd&lt;/code&gt; to set the directory Claude Code works in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run a2claude serve &lt;span class="nt"&gt;--backend&lt;/span&gt; claude &lt;span class="nt"&gt;--cwd&lt;/span&gt; /path/to/project
uv run a2claude call &lt;span class="s2"&gt;"add a /health endpoint"&lt;/span&gt; &lt;span class="nt"&gt;--url&lt;/span&gt; http://localhost:9100/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Ask for a follow-up
&lt;/h3&gt;

&lt;p&gt;Pass the &lt;code&gt;context&lt;/code&gt; that came back from the previous turn and it becomes a continuation of the same conversation.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run a2claude call &lt;span class="s2"&gt;"now add a test for it"&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &amp;lt;context-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Try the permission exchange
&lt;/h3&gt;

&lt;p&gt;Send an action that needs approval and it stops at &lt;code&gt;input-required&lt;/code&gt; and asks for an answer. The answer goes to the same Task.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run a2claude call &lt;span class="s2"&gt;"sudo reboot"&lt;/span&gt;
&lt;span class="c"&gt;# ... [input-required] Permission requested for Bash: $ sudo reboot&lt;/span&gt;
&lt;span class="c"&gt;#       reply: a2claude call "allow" --task &amp;lt;id&amp;gt; --context &amp;lt;id&amp;gt;&lt;/span&gt;
uv run a2claude call &lt;span class="s2"&gt;"allow"&lt;/span&gt; &lt;span class="nt"&gt;--task&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;--context&lt;/span&gt; &amp;lt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;echo&lt;/code&gt; backend also asks for approval when the prompt contains &lt;code&gt;sudo&lt;/code&gt;, so you can verify &lt;strong&gt;just this exchange&lt;/strong&gt; without driving Claude.&lt;/p&gt;

&lt;h3&gt;
  
  
  Peek at the Agent Card
&lt;/h3&gt;

&lt;p&gt;This is the business card the other agent reads first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;uv run a2claude card
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;a2claude does not lump Claude Code's abilities into one "chat box". It lists them as &lt;strong&gt;discrete skills&lt;/strong&gt; on the Agent Card: code generation, refactor, debug, review, test, and code explanation, six of them. The caller can aim a request, "this is a debug job", deliberately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;What a2claude does comes down to one mapping table: it maps the structured events Claude Code emits onto A2A's Task, Artifact, status, and contextId. It does not flatten them to text.&lt;/p&gt;

&lt;p&gt;Three design points to single out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The translation layer does not know Claude's SDK.&lt;/strong&gt; With a backend abstraction in between, &lt;code&gt;echo&lt;/code&gt; alone verifies the whole path offline, and swapping out how Claude is invoked later leaves the translation layer untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions map to &lt;code&gt;input-required&lt;/code&gt;.&lt;/strong&gt; Keeping a parked session alive across two calls means it never auto-approves and never silently skips. The decision always returns to the caller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;contextId makes the conversation continuous.&lt;/strong&gt; Mapping the Claude session id to the A2A context remembers "the previous turn".&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you are looking for a "writes code" slot in a setup where agents hand work to each other, you can drop Claude Code straight in as a part. The code is small enough to read all of, so take a look, and if something is off, throw an Issue.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/kanywst/a2claude" rel="noopener noreferrer"&gt;github.com/kanywst/a2claude&lt;/a&gt;&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>ai</category>
      <category>agents</category>
      <category>python</category>
    </item>
  </channel>
</rss>
